Installation
The engine is copy-in, not an npm package — the same model as shadcn/ui. A one-command installer copies the source into your own Next.js project and rewrites it to be self-contained, or copy it in by hand — either way you own it from that point on; there is no runtime dependency on this repo.
1. Prerequisites
These need to already be true in your project — nothing the installer does can substitute for them.
- A React 19 project.The copied engine itself has no Next.js-specific coupling, but the installer's own base-folder detection (
src/→ elseapp/→ else your project root) assumes a Next.js-shaped layout. - Tailwind CSS v4, CSS-first config.
@import "tailwindcss";in your global stylesheet, notailwind.config.js. Without it, neither the customtablet:/desktop:breakpoints nor any shadcn token-driven utility the copied fields use (bg-primary,border-input, …) compiles to anything. - shadcn's own foundation already in that stylesheet.See below — the installer only adds to it, it doesn't create it.
The installer's theme step is additive only: it writes the engine's own breakpoints, accent tokens, and sizing scale on top of an existing shadcn foundation — it does not scaffold that foundation itself (--background/--primary/--border/--radius, @custom-variant dark, the base border/outline layer rule, plus the shadcn and tw-animate-css packages' own @import lines). If this project has never used shadcn/ui, run npx shadcn@latest init once first — it scaffolds exactly that (nothing else here needs the components.json it also writes). Already have shadcn/ui components somewhere in this app? You already have it.
Runtime peer dependencies — the installer never installs these itself (a duplicate React, React Hook Form, or Zod instance is the actual footgun, not a missing install step):
pnpm add react react-dom react-hook-form zod date-fns lucide-react2. Install (one command)
Not on npm yet — npx form-builder-nextjswon't resolve for anyone outside this repo until it's published. Two honest options right now: run it from a checkout of this repo (below), or use the manual copy-in fallback further down this page. Once it ships to npm, npx form-builder-nextjs is the one-command install.
One command copies the engine, every built-in field, the 17 vendored shadcn primitives, and the theme tokens into a single self-contained <base>/form-builder/ folder (src/ if your project has one, else app/, else the project root). Every @/components/ui/* import inside the copy is rewritten to a relative path on the way in — zero alias setup to do, and the folder works regardless of your own tsconfig.json.
# from a checkout of this repo, targeting another project on disk
node cli/bin/form-builder.mjs --cwd ../my-appOnly need a few field types? Install a scoped subset instead — it pulls in just the requested fields plus whatever slice of the engine and primitives they actually need, not the full 17:
node cli/bin/form-builder.mjs add text phone --cwd ../my-appRe-running is safe by default: existing files are left alone so your edits survive, only newly-added items get written. Pass --force to overwrite everything anyway, or --no-install/--no-theme to skip the npm-install / globals.css-write steps and handle them yourself. Run node cli/bin/form-builder.mjs --help for the full flag list.
3. What you got
Everything lands together under one folder, self-contained:
src/form-builder/
├── core/ # types, zod schema/validation, conditions, messages, registry — framework-agnostic
├── hooks/ # useDynamicForm, useOtpFlow, useOtpController
├── store/ # zustand stepper store
├── ui/ # FieldWrapper, cva variants, the 12-col grid layout
├── components/ # FormRenderer, FormStepper, FieldRuntime, ReviewStep, ...
│ └── ui/ # the shadcn primitives this install needed — vendored, import-rewritten
├── fields/ # one component per field type (+ index.ts's registerBuiltInFields, whole-tree installs only)
└── internal/ # the cn() class-merge helper (was @/lib/utils)It's yours from the moment it lands — same "copy it, own it" model as the manual copy-in further down this page, just automated. Two differences from that manual flow worth knowing: every primitive under components/ui/ here is a copy scoped to this folder, not shared with any components/ui/ you already have elsewhere in the app; and there is no single package entry point — the copied tree has no root index.ts barrel, so you import straight from whichever submodule has what you need (next section shows the shape).
tw-animate-css gets npm install -D'd for you automatically as part of the theme step. The base shadcn package and its own @import lines do not — see Prerequisites, above.
4. Use it
Assuming your project has the default Next.js @/* → <base>/*alias (swap for a relative import if it doesn't):
import type { FormConfig } from "@/form-builder/core/types";
import { FormRenderer } from "@/form-builder/components/FormRenderer";
import { registerBuiltInFields } from "@/form-builder/fields";
registerBuiltInFields(); // once, e.g. app/layout.tsx — before any FormRenderer mounts
const config: FormConfig = {
id: "contact",
fields: [
{ type: "text", name: "name", label: "Name", required: true },
{ type: "email", name: "email", label: "Email", required: true },
{ type: "submit", name: "submit", text: "Submit" },
],
};
export function ContactForm() {
return (
<FormRenderer
config={config}
onSubmit={(values) => {
// values: { name: string; email: string }
console.log(values);
}}
/>
);
}Installed a subset instead (add text phone)? There's no fields/index.tsaggregate to call — register each installed type yourself, the same name → component mapping this repo's own fields/index.ts uses:
import { registerField } from "@/form-builder/core/registry";
import { TextField } from "@/form-builder/fields/TextField";
import { PhoneField } from "@/form-builder/fields/PhoneField";
registerField("text", TextField);
registerField("phone", PhoneField);5. Troubleshooting
- "Cannot find module 'react-hook-form'" (or zod/date-fns/lucide-react/react/react-dom) — the installer only installs the copied fields' own leaf dependencies, never these peers (Prerequisites, above). Install them yourself.
tablet:/desktop:classes doing nothing — the theme step never found aglobals.cssto write into. Check what the install printed to the terminal (it prints the block for you to paste manually when it can't find one), or search your stylesheet for the/* form-builder theme (managed) */sentinel comment to confirm whether it ran at all.- Breakpoints work, but primitives still render with no visible border or fill — the shadcn base foundation isn't there yet (see Prerequisites): the installer's theme write only layers its own tokens on top of that foundation, it doesn't scaffold it. Run
npx shadcn@latest initonce. - "Field type not registered" at render — whole-tree install:
registerBuiltInFields()wasn't called before the firstFormRenderermount. Subset install: there's no aggregate to call — register each installed type withregisterFieldyourself (see Use it, above). - A changed field didn't update after re-running the installer — that's the clobber protection working as designed: existing files are skipped so your own edits survive a re-run. Pass
--forceto overwrite.
6. Copy the package folder (manual fallback)
Prefer not to run the CLI above — or can't, since it isn't published outside this repo yet? Copy the source in by hand instead; it's the same flow this page taught before the CLI existed. It doesn't get the single-folder self-containment or import-rewriting from the steps above: the copied form-builder/ folder keeps its own @/ aliases as-is, so it needs to be reachable via a @/form-builder (or equivalent) alias in your own tsconfig.json, and the shadcn primitives stay wherever your own components/ui/ already lives instead of being vendored alongside it.
Copy the form-builder/ folder into your Next.js project as-is. It has no dependency on anything else in this repo — components/ui/ (below) and the peer packages it imports.
7. Add the shadcn primitives
The engine's fields are built on these shadcn primitives — it's the exact set under components/ui/ in this repo, so check your own components/ui/ before re-adding anything you already have:
pnpm dlx shadcn@latest add button calendar checkbox command dialog field \
input input-group input-otp label popover progress radio-group \
select separator slider switch textareashadcn itself stays a devDependency— it's a codegen CLI that writes files into your repo at install time, not a library your bundle ships at runtime. Its base layer still has to reach your CSS though: add this import to your global stylesheet (this repo does it in app/globals.css):
@import "shadcn/tailwind.css";8. Set up the required CSS
Three things the copied engine silently depends on and that nothing above wires up yet. First, tablet: and desktop: aren't stock Tailwind breakpoints — they only exist because this repo declares them in @theme. Without that, every tablet:/desktop:class the engine ships compiles to nothing and the layout never leaves its mobile styles. Second, the shadcn primitives from step 2 (and a few of the engine's own field wrappers) render color through utilities like bg-primary, text-muted-foreground, and border-destructive — Tailwind v4 only generates those classes when the matching --color-* key exists in @theme, so skipping the token definitions doesn't error, it just renders fields with no visible borders or fill.
Third, @custom-variant dark (&:is(.dark *));— without it, Tailwind's default dark: variant follows the OS-level prefers-color-scheme media query instead of a .dark class on an ancestor. Several field components ship dark:-conditional classes (e.g. OtpField's slot fill, PhoneField's input background and valid-state border) — skip this line and those only flip when the OS theme does, independently of whatever theme toggle your own app uses.
Add this to the same global stylesheet as the @import "shadcn/tailwind.css";line from step 2 (this repo's copy lives in app/globals.css) — it's a trimmed copy of this repo's actual tokens, not a generic palette:
@custom-variant dark (&:is(.dark *));
@theme inline {
--breakpoint-tablet: 481px;
--breakpoint-desktop: 1025px;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 10px;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}The trailing @layer base block matters even though nothing in form-builder/ or the shadcn add-list uses a bare border/outlineutility today — without it Tailwind's default border/outline color is currentColor, not --color-border/--color-ring, so a bare-border primitive you add later (e.g. shadcn's alert) renders with a mismatched or invisible edge and every element loses its focus-visible outline color.
Don't want dark mode? Drop the @custom-variant dark line and the .dark block — the :rootvalues apply everywhere. Want exact visual parity with this site instead of picking your own palette? Copy this repo's entire app/globals.css — the tokens left out above (--color-card, --color-chart-*, --color-sidebar-*, the brand-accent and interactive-border tokens) are landing-page/builder styling this site uses for itself, not anything form-builder/ or the shadcn add-list reads.
One more thing if you drop form-builder/somewhere Tailwind doesn't already scan — a package folder, a node_modules path, anywhere outside your configured @sourceglobs. Tailwind v4 only generates the utility classes it can see in scanned files, so the engine's classes silently won't exist. Point the scanner at the copied folder in the same global stylesheet: @source "../path/to/form-builder";. Copy it straight into your app's own source tree and this is already covered.
Optional — retheme sizing without touching a component. The engine sizes everything in viewport-width (vw) units, but every size ships as a var(--fb-space-*, <default>) reference with the vw value as its fallback. Leave the tokens undefined and you get the defaults; define any of them, in any unit (px, rem, %, clamp()…), to override. Each step has three independent breakpoint tiers — --fb-space-N (mobile), --fb-space-N-tablet, --fb-space-N-desktop — and changing your --breakpoint-tablet/--breakpoint-desktop values retargets which tier applies. The full step list ships as form-builder/theme/tokens.css (import it to see every knob, or just override the few you want):
:root {
/* Retheme any step in any unit — every field that uses it follows.
step = tablet-vw / 0.25; three independent tiers per step. */
--fb-space-8-desktop: 1rem; /* fixed instead of the 0.832vw default */
--fb-space-3-tablet: 8px; /* was 0.75vw */
}Rather not hand-write those, or want a different unit for the whole scale? Generate the file right here — the same Sizing CSS panel the builder ships in its header. Pick a unit and it rewrites the whole token set: vw keeps the fluid engine defaults (sizes scale within a breakpoint band), while px/rem/em emit fixed sizes — constant within a band, jumping at the breakpoint.
For the fixed units you also set a reference viewport width per breakpoint— the width the engine's vw scale is resolved at. The defaults (mobile 375, tablet 800, desktop 1920) regenerate the engine's original design exactly: a 2px-per-step scale, so step 8 lands on 16px on every tier, step 7 on 14px, and so on. Drop the desktop reference to 1440 and that same step 8 becomes 12px. rem/em additionally take a base (px per 1rem, default 16). The panel previews the output live; when it looks right, Download tokens.css (or copy it) and drop the file into your copied form-builder/theme/.
9. Install the runtime peer dependencies
These are the libraries the copied field components actually import at runtime:
pnpm add react-hook-form @hookform/resolvers zod zustand \
class-variance-authority clsx tailwind-merge cmdk date-fns \
react-day-picker input-otp libphonenumber-js react-phone-number-input \
signature_pad lucide-react radix-ui tw-animate-cssPlus tailwindcss@^4 and @tailwindcss/postcssif your project isn't already on Tailwind 4.
10. Register the built-in fields
Field types render through a registry, not a switch statement — nothing renders until it's registered. Call this once, before the first FormRenderermounts (a root layout or app entry point works; it's safe to call more than once):
// e.g. app/layout.tsx, once, before any FormRenderer mounts
import { registerBuiltInFields } from "@/form-builder";
registerBuiltInFields();11. Import only from the package entry point
form-builder/index.tsis the package's only supported import path — it's the public API surface (FormRenderer, useDynamicForm, types, and the rest of the exports live there). Reaching into form-builder/core or form-builder/fields directly is unsupported — those modules can be restructured without notice.
// Correct — the package's one public entry point
import { FormRenderer, useDynamicForm } from "@/form-builder";
// Wrong — nothing outside index.ts is a supported import path
import { FormRenderer } from "@/form-builder/components/FormRenderer";Next: build your first form.