npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@classytic/formkit

v1.5.0

Published

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

Downloads

308

Readme

@classytic/formkit

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

npm License: MIT TypeScript

Features

  • Headless - Bring your own UI components (Shadcn, MUI, Chakra, etc.) via a one-time component/layout adapter. Already using shadcn? Skip the adapter entirely with the prebuilt one in @classytic/fluid/formkit — its SchemaForm is genuinely a schema + onSubmit away.
  • Minimal boilerplate - once an adapter is registered, useFormKit wires a full form (defaults + RHF + generator props) in a few lines
  • Schema-driven - Define forms with JSON/TypeScript schemas, defaults extracted automatically
  • Type-safe - Full TypeScript support with generics
  • React Hook Form - Built on top of the best form library, referentially stable return values
  • React 19 - Uses modern React 19 patterns (Context as provider, ref as prop)
  • Server Components - Dedicated @classytic/formkit/server entry point for RSC
  • Variants - Support for multiple component variants
  • Conditional fields - Show/hide fields based on form values (function, DSL rules, AND/OR logic)
  • Responsive layouts - Multi-column grid layouts
  • Accessibility - Auto-generated fieldId, error, and fieldState props
  • Validation helpers - buildValidationRules generates RHF rules from schema props
  • Lightweight - ~17KB gzipped (peer deps excluded, everything included), tree-shakeable

Requirements

  • React 19.0+ (React 18 is not supported)
  • React Hook Form 7.55.0+

Installation

npm install @classytic/formkit react-hook-form
# or
pnpm add @classytic/formkit react-hook-form
# or
yarn add @classytic/formkit react-hook-form

Quick Start

1. Create Field Components

Each field component receives FieldComponentProps including error, fieldId, and the full field config:

// components/form/form-input.tsx
"use client";

import { Controller } from "react-hook-form";
import type { FieldComponentProps } from "@classytic/formkit";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function FormInput({
  control,
  name,
  rules,       // pre-computed RHF rules from schema (required, minLength, pattern, validate…)
  label,
  placeholder,
  required,
  fieldId,     // use as id on <input> and htmlFor on <Label>
  errorId,     // use as id on error <p> and aria-errormessage on <input>
  shouldShowError, // true only after touch/submit — mirrors :user-invalid timing
  error,
}: FieldComponentProps) {
  return (
    <Controller
      name={name}
      control={control}
      rules={rules}
      render={({ field }) => (
        <div className="space-y-2">
          {label && (
            <Label htmlFor={fieldId}>
              {label}
              {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
            </Label>
          )}
          <Input
            {...field}
            id={fieldId}
            placeholder={placeholder}
            aria-required={required || undefined}
            aria-invalid={shouldShowError || undefined}
            aria-errormessage={shouldShowError ? errorId : undefined}
          />
          {shouldShowError && (
            <p id={errorId} role="alert" className="text-sm text-red-500">
              {error?.message}
            </p>
          )}
        </div>
      )}
    />
  );
}

2. Create Form Adapter

Register your components and layouts:

// lib/form-adapter.tsx
"use client";

import {
  FormSystemProvider,
  type ComponentRegistry,
  type LayoutRegistry,
} from "@classytic/formkit";
import { FormInput } from "@/components/form/form-input";

const components: ComponentRegistry = {
  text: FormInput,
  email: FormInput,
  password: FormInput,
  // Add more field types...
};

const layouts: LayoutRegistry = {
  section: ({ title, description, children }) => (
    <div className="space-y-4">
      {title && <h3 className="text-lg font-semibold">{title}</h3>}
      {description && <p className="text-muted-foreground">{description}</p>}
      {children}
    </div>
  ),
  grid: ({ children, cols = 1 }) => {
    // Map to STATIC class names. A template like `grid-cols-${cols}` is never
    // generated by Tailwind's JIT (it only sees whole class strings in source).
    const COLS: Record<number, string> = {
      1: "grid-cols-1",
      2: "grid-cols-1 md:grid-cols-2",
      3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
    };
    return <div className={`grid gap-4 ${COLS[cols] ?? "grid-cols-1"}`}>{children}</div>;
  },
};

export function FormProvider({ children }: { children: React.ReactNode }) {
  return (
    <FormSystemProvider components={components} layouts={layouts}>
      {children}
    </FormSystemProvider>
  );
}

3. Use FormGenerator

// app/signup/page.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormGenerator, useFormKit, type FormSchema } from "@classytic/formkit";
import { FormProvider } from "@/lib/form-adapter";

const signupSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

type SignupData = z.infer<typeof signupSchema>;

const formSchema: FormSchema<SignupData> = {
  sections: [
    {
      title: "Personal Information",
      cols: 2,
      fields: [
        { name: "firstName", type: "text", label: "First Name", required: true, defaultValue: "" },
        { name: "lastName", type: "text", label: "Last Name", required: true, defaultValue: "" },
      ],
    },
    {
      title: "Account",
      fields: [
        { name: "email", type: "email", label: "Email", required: true, defaultValue: "" },
        { name: "password", type: "password", label: "Password", required: true, defaultValue: "" },
      ],
    },
  ],
};

export default function SignupPage() {
  const { handleSubmit, generatorProps } = useFormKit({
    schema: formSchema,
    resolver: zodResolver(signupSchema),
  });

  return (
    <FormProvider>
      <form onSubmit={handleSubmit(console.log)} className="space-y-8">
        <FormGenerator {...generatorProps} />
        <button type="submit">Sign Up</button>
      </form>
    </FormProvider>
  );
}

Type safety

Field names are enforced against your form type at authoring time by the builders. Use field.for<T>(), defineField<T>(), or section<T>() and a typo is a compile error:

const f = field.for<SignupData>();
f.text("emial", "Email"); // ✗ compile error — "emial" is not a key of SignupData
f.text("email", "Email"); // ✓

The <FormGenerator schema> / useFormKit({ schema }) props themselves accept FormSchema<any> by design: a schema's condition functions ((values) => …) make FormSchema<T> contravariant in T, so pinning the prop to the form's T would force a cast at every call site. Author with the typed builders for name safety; the generator stays permissive so any built schema drops in without ceremony. itemFields (group/array children) use relative names and are resolved at render time, so they are typed loosely on purpose.

API Reference

useFormKit

Convenience hook that combines schema default extraction with react-hook-form setup. Returns all useForm methods plus ready-to-spread generatorProps.

Referentially stable — the return value preserves the original useForm object identity across re-renders, so it's safe to use in useEffect dependency arrays.

import { useFormKit, FormGenerator } from "@classytic/formkit";

const form = useFormKit({
  schema: formSchema,
  resolver: zodResolver(validationSchema), // optional
  defaultValues: { email: "[email protected]" }, // optional overrides
  disabled: false,                          // optional
  variant: "compact",                       // optional
  className: "my-form",                     // optional
  resetOnSchemaChange: true,                // optional — re-seed on schema swap
  mode: "onBlur",                           // any useForm option
});

const { handleSubmit, generatorProps } = form;

// Safe to use in useEffect deps — form is referentially stable
useEffect(() => {
  if (open) form.reset(defaults);
}, [open, form]);

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <FormGenerator {...generatorProps} />
    <button type="submit">Submit</button>
  </form>
);

Schema defaultValue fields are automatically extracted and merged with any explicit defaultValues you provide (explicit values take priority). Nested/namespaced defaults are extracted as a nested object ({ address: { city } }), so react-hook-form's dot-path resolution applies them correctly.

Pass resetOnSchemaChange: true to re-seed the form when the schema identity changes at runtime (e.g. a schema loaded from a DB is swapped in). It's off by default because a reset discards in-progress edits.

generatorProps is memoized — it only recomputes when schema, control, disabled, variant, or className change.

FormGenerator

The main component that renders forms from a schema. Supports React 19 ref as a regular prop.

<FormGenerator
  schema={formSchema}       // Required: Form schema
  control={form.control}    // Optional: React Hook Form control (or wrap in <FormProvider>)
  disabled={false}           // Optional: Disable all fields
  variant="default"          // Optional: Global variant
  className="my-form"        // Optional: Root element class
  ref={formRef}              // Optional: Ref to the root <div> (React 19 ref-as-prop)
/>

FormSchema

interface FormSchema<T extends FieldValues = FieldValues> {
  sections: Section<T>[];
}

Section

interface Section<T> {
  id?: string;                  // Unique identifier
  title?: string;               // Section title
  description?: string;         // Section description
  icon?: ReactNode;             // Section icon
  fields?: BaseField<T>[];      // Fields in this section
  cols?: number;                // Grid columns (1-6)
  gap?: number;                 // Grid gap
  variant?: string;             // Section variant
  className?: string;           // Custom class
  collapsible?: boolean;        // Make section collapsible
  defaultCollapsed?: boolean;
  nameSpace?: string;           // Prefix for nested object fields (e.g. "address")

  // Conditional rendering (function, DSL rule, or ConditionConfig)
  condition?: Condition<T>;

  // Custom render function (bypasses grid layout)
  render?: (props: SectionRenderProps<T>) => ReactNode;
}

BaseField

interface BaseField<T> {
  // name accepts Path<T> or any string.
  // Use field.for<T>() builders for call-site enforcement of valid paths.
  // Relative names are correct for nameSpace sections and itemFields children —
  // FormGenerator prefixes them at render time (e.g. "street" → "address.street").
  name: Path<T> | string;       // Field name (required)
  type: FieldType;              // Field type (required)
  label?: string;               // Field label
  placeholder?: string;         // Placeholder text
  helperText?: string;          // Helper text below field
  disabled?: boolean;           // Disable field
  required?: boolean;           // Mark as required
  readOnly?: boolean;           // Read-only field
  variant?: string;             // Field variant
  fullWidth?: boolean;          // Span full grid width
  className?: string;           // Custom class
  defaultValue?: unknown;       // Default value

  // Conditional rendering
  condition?: Condition<T>;
  watchNames?: string | string[];  // Optimize useWatch performance

  // Dynamic options loading
  loadOptions?: (formValues: Partial<T>) => Promise<FieldOption[]> | FieldOption[];
  debounceMs?: number;

  // Sub-fields for group and array types.
  // Children use relative names ("street") — FormGenerator prefixes with parent name.
  // Intentionally untyped to T because they resolve at render time, not authoring time.
  itemFields?: BaseField[];

  // For select/radio/checkbox
  options?: FieldOption[];

  // HTML input attributes
  min?: number | string;
  max?: number | string;
  step?: number;
  pattern?: string;
  minLength?: number;
  maxLength?: number;
  rows?: number;
  multiple?: boolean;
  accept?: string;
  autoComplete?: string;
  autoFocus?: boolean;

  // Custom render override
  render?: (props: FieldComponentProps<T>) => ReactNode;

  // Arbitrary extra props for custom components
  customProps?: Record<string, unknown>;
}

FieldComponentProps

Props passed to your field components:

interface FieldComponentProps<T extends FieldValues = FieldValues>
  extends BaseField<T> {
  field: BaseField<T>;          // Full field config
  control: Control<T>;          // React Hook Form control
  disabled?: boolean;           // Merged disabled state
  variant?: string;             // Active variant
  error?: FieldError;           // Field error from react-hook-form
  fieldState?: {                // Field state metadata
    invalid: boolean;
    isDirty: boolean;
    isTouched: boolean;
    isValidating: boolean;
    isSubmitted: boolean;
    error?: FieldError;
  };
  fieldId: string;              // Generated ID for label-input association (e.g. "formkit-field-email")
  errorId: string;              // ID for the error node — set as aria-errormessage on the input
  shouldShowError: boolean;     // true only after touch/submit (mirrors :user-invalid timing)
  rules: RegisterOptions;       // Pre-computed RHF rules from schema (required/min/max/pattern…)
  isLoading?: boolean;          // true while async loadOptions is in flight
}

Condition Types

Conditions can be a function, a DSL rule, an array of rules (AND), or a ConditionConfig (AND/OR):

// Function condition
condition: (values) => values.accountType === "business"

// Single DSL rule
condition: { watch: "country", operator: "===", value: "US" }

// Array of rules (AND - all must match)
condition: [
  { watch: "country", operator: "===", value: "US" },
  { watch: "age", operator: "truthy" },
]

// ConditionConfig with OR logic
condition: {
  rules: [
    { watch: "country", operator: "===", value: "US" },
    { watch: "country", operator: "===", value: "CA" },
  ],
  logic: "or",
}

Supported operators: ===, !==, in, not-in, truthy, falsy

Nested paths: DSL rules support dot-notation paths like "address.city" for nested form values.

ComponentRegistry

const components: ComponentRegistry = {
  // Simple mapping
  text: FormInput,
  select: FormSelect,

  // Variant-specific components
  compact: {
    text: CompactInput,
    select: CompactSelect,
  },
};

LayoutRegistry

const layouts: LayoutRegistry = {
  section: SectionLayout,
  grid: GridLayout,

  // Variant-specific layouts
  compact: {
    section: CompactSection,
  },
};

extractDefaultValues

Extracts default values from a schema. Server-safe (no hooks).

import { extractDefaultValues } from "@classytic/formkit"; // or /server

const defaults = extractDefaultValues(formSchema);
// { firstName: "", lastName: "", email: "", password: "" }

// Use with react-hook-form
const form = useForm({ defaultValues: defaults });

Respects nameSpace prefixes and group itemFields defaults.

buildValidationRules

Generates react-hook-form validation rules from a field's schema props. Server-safe (no hooks).

import { buildValidationRules } from "@classytic/formkit"; // or /server

function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
  const rules = buildValidationRules(field);
  return (
    <Controller
      name={field.name}
      control={control}
      rules={rules}
      render={({ field: rhf }) => <input {...rhf} id={fieldId} />}
    />
  );
}

Maps required, min, max, minLength, maxLength, and pattern from the field schema to RHF-compatible rules with auto-generated error messages.

Validation & helpers

validateSchema

Structurally validate a schema (great for DB-loaded / resetOnSchemaChange schemas). Server-safe; returns SchemaIssue[] (empty ⇒ OK). FormGenerator also runs it automatically in dev and console.warns any issues.

import { validateSchema } from "@classytic/formkit"; // or /server

for (const issue of validateSchema(schema)) {
  console.warn(`${issue.severity} ${issue.path}: ${issue.message}`);
}
// Detects: missing name/type, duplicate names (namespace-aware),
// itemFields on a non-container, empty containers, unknown DSL operators.

It validates shape, not your component registry — an unregistered field type is a rendering concern, surfaced separately (see onMissingComponent).

focusFirstError

Focus + scroll to the first visibly-invalid field, in layout order. Wire it into the submit's invalid handler:

import { focusFirstError } from "@classytic/formkit";

<form onSubmit={handleSubmit(onValid, () => focusFirstError())}>

React Hook Form calls the invalid handler before React commits the re-render that marks fields invalid, so if the immediate query finds nothing, focusFirstError automatically retries on the next two animation frames — the plain wiring above works on the very first submit. It returns true when a field was focused immediately, false when the attempt was deferred.

onMissingComponent

If a field's type has no registered component, the form renders a visible placeholder (dev and prod — never a silent drop or a wrong-widget text input) and notifies this callback so you can log the gap. hidden fields are the one exception: with no registered component they render nothing by design (the value still lives in form state); register a hidden component to override.

<FormSystemProvider
  components={components}
  layouts={layouts}
  onMissingComponent={(type) => reportToTelemetry("formkit_missing_component", { type })}
>

Security note: buildValidationRules compiles a field's pattern into a new RegExp(...). If your schemas are ever authored by untrusted users, treat pattern as a ReDoS surface — validate/limit it before persisting.

Server Components

The @classytic/formkit/server entry point exports server-safe utilities with no React hooks or client-side code:

import {
  cn,
  defineSchema,
  defineField,
  defineSection,
  evaluateCondition,
  extractWatchNames,
  extractDefaultValues,
  buildValidationRules,
} from "@classytic/formkit/server";

// Type-only imports also available
import type {
  FormSchema,
  BaseField,
  Section,
  ConditionRule,
  ConditionConfig,
} from "@classytic/formkit/server";

Use this entry point in React Server Components to define schemas, evaluate conditions, or use cn without pulling in client-side code.

Passing a schema across the RSC → Client boundary

FormGenerator is a Client Component (it drives react-hook-form). If you build a schema in a Server Component and pass it as a prop into the client, React requires that prop to be serializable — so function members won't cross the boundary:

  • ❌ function condition: (values) => …, custom render, and loadOptions (functions aren't serializable)
  • DSL conditions ({ watch, operator, value }), and all the plain data fields (name, type, label, options, required, min/max, …)

For fully static, server-defined schemas use DSL conditions. If a schema needs function conditions / custom renders / async options, define it inside the Client Component (e.g. the file that renders FormGenerator) rather than passing it down from an RSC. This is a general Next.js constraint, not specific to formkit.

Advanced Features

Conditional Fields (Function)

{
  name: "companyName",
  type: "text",
  label: "Company Name",
  condition: (values) => values.accountType === "business",
}

Performance: a function condition can't be statically analyzed, so the field subscribes to the whole form and re-evaluates on every change. For large forms prefer a DSL rule (below, whose watched paths are auto-scoped), or add watchNames to scope the subscription yourself: { condition: (v) => v.accountType === "business", watchNames: ["accountType"] }.

Conditional Fields (DSL Rules)

{
  name: "stateField",
  type: "select",
  label: "State",
  condition: { watch: "country", operator: "===", value: "US" },
  watchNames: ["country"], // Optimizes re-renders
}

Conditional Sections

{
  title: "Business Details",
  condition: (values) => values.accountType === "business",
  fields: [
    { name: "companyName", type: "text", label: "Company" },
    { name: "taxId", type: "text", label: "Tax ID" },
  ],
}

OR Conditions

{
  name: "taxField",
  type: "text",
  condition: {
    rules: [
      { watch: "country", operator: "===", value: "US" },
      { watch: "country", operator: "===", value: "CA" },
    ],
    logic: "or",
  },
}

Nested Path Conditions

DSL rules resolve dot-notation paths for nested form values:

{
  name: "zipCode",
  type: "text",
  condition: { watch: "address.country", operator: "===", value: "US" },
}

Namespace Support

Prefix all field names in a section with a namespace for nested objects:

{
  nameSpace: "address",
  fields: [
    { name: "street", type: "text" },  // Becomes "address.street"
    { name: "city", type: "text" },    // Becomes "address.city"
  ],
}

Variants

Apply different styles based on context:

// Register variant-specific components
const components = {
  text: DefaultInput,
  compact: {
    text: CompactInput,
  },
};

// Use variant on the whole form
<FormGenerator schema={schema} variant="compact" />

// Or per-section
{ variant: "compact", fields: [...] }

// Or per-field
{ name: "notes", type: "text", variant: "compact" }

Dynamic Options Loading

{
  name: "city",
  type: "select",
  watchNames: ["country"],
  loadOptions: async (values, { signal }) => {
    // Forward `signal` so a superseded/unmounted load is cancelled.
    const cities = await fetchCities(values.country, { signal });
    return cities.map(c => ({ label: c.name, value: c.id }));
  },
  debounceMs: 300,
  cacheOptions: true, // reuse the last fetch when a dependency is re-selected
}

cacheOptions (default false) memoizes results per watched-value signature for the field's lifetime, so toggling country back and forth doesn't re-hit the API. Leave it off when the options can change server-side between loads.

Custom Section Render

{
  title: "Payment",
  render: ({ control, disabled }) => (
    <StripeElements>
      <CardElement />
      <FormInput name="billingName" control={control} />
    </StripeElements>
  ),
}

Custom Field Render

{
  name: "avatar",
  type: "file",
  render: ({ field, control, error, fieldId }) => (
    <AvatarUploader fieldId={fieldId} error={error} />
  ),
}

Custom Props

Pass arbitrary props to your field components via customProps:

{
  name: "bio",
  type: "textarea",
  label: "Biography",
  customProps: {
    maxCharacters: 500,
    showCounter: true,
  },
}

Access in your component:

function FormTextarea({ field, customProps, ...props }: FieldComponentProps) {
  const maxChars = customProps?.maxCharacters as number;
  // ...
}

Grouped Select Options

{
  name: "country",
  type: "select",
  options: [
    {
      label: "North America",
      options: [
        { value: "us", label: "United States" },
        { value: "ca", label: "Canada" },
      ],
    },
    {
      label: "Europe",
      options: [
        { value: "uk", label: "United Kingdom" },
        { value: "de", label: "Germany" },
      ],
    },
  ],
}

Schema Builder Utilities

Type-safe helpers for defining schemas outside of components:

import { defineSchema, defineField, defineSection } from "@classytic/formkit/server";

const emailField = defineField<MyFormData>({
  name: "email",
  type: "email",
  label: "Email Address",
  required: true,
});

const personalSection = defineSection<MyFormData>({
  title: "Personal Info",
  cols: 2,
  fields: [emailField],
});

const schema = defineSchema<MyFormData>({
  sections: [personalSection],
});

Type Exports

import type {
  // Core
  FormSchema,
  FormGeneratorProps,
  BaseField,
  Section,

  // Components
  FieldComponentProps,
  FieldComponent,
  ComponentRegistry,

  // Layouts
  SectionLayoutProps,
  GridLayoutProps,
  LayoutComponent,
  LayoutRegistry,

  // Options
  FieldOption,
  FieldOptionGroup,

  // Conditions
  ConditionRule,
  ConditionConfig,
  Condition,

  // Hook types
  UseFormKitOptions,
  UseFormKitReturn,

  // Utility types
  FieldType,
  LayoutType,
  Variant,
  DefineField,
  InferSchemaValues,
  SchemaFieldNames,
  FormElement,
} from "@classytic/formkit";

Browser Support

  • React 19.0+
  • All modern browsers

License

MIT © Classytic

Links