Widgets

The widgets formdef ships for shadcn, and what every one of them has in common.

formdef's widgets are a shadcn registry. shadcn add copies their source into your project, where it is yours to change. Installation adds the shadcn registry to components.json and fetches the setup; each page in this section shows one widget live, the command that fetches it, and the files it brings.

Where the files land

Every file lands in a formdef/ folder of its own, under the aliases of your components.json:

components/formdef/text-field.tsx   the widget
components/formdef/field-frame.tsx  shared by the widgets with a label above the control
lib/formdef/index.tsx               your registry, imported as @/lib/formdef
lib/formdef/context.ts              the TanStack contexts

A file several widgets share, such as field-frame.tsx, comes with each of them. shadcn's own primitives, such as input or popover, come as dependencies and stay where your ui alias points.

Registering a widget

A widget renders nothing until the registry names it. Add it under widgets in lib/formdef/index.tsx; the key is the name a field entry writes as widget:

lib/formdef/index.tsx
'use client'

import {  } from '@formdef/core'

import {  } from '@/components/formdef/text-field'
import {  } from '@/components/ui/field'
import { ,  } from './context'

export const  = ({
  ,
  ,
  : { :  },
  : {},
  : { :  },
})

export const { createForm, Fields,  } = 

What every field widget takes

Next to the field formdef passes, each widget takes its own props, listed on its page. A few work the same everywhere:

PropMeaning
labelThe text of the <label>.
descriptionA hint below the control, tied to it with aria-describedby.
labelAsideContent on the other side of the label, such as a link.
classNameClasses for the control, not for the wrapper around it.

className lands on the control, so a narrow field stays narrow while its label and message keep the full width:

export const  = createForm({
  : .({ : .().(5, 'Enter five digits') }),
  : { : '' },
  fields: {
    zip: { : 'text', : { : 'ZIP code', : 'w-[7ch]' } },
  },
})

labelAside renders beside the <label>, never inside it. A link inside a label is also a click on the label, so it would focus the field instead of following the link. Checkbox, radio and switch do not take it: their label already sits beside the control.

Server and client

The files that hold state or call hooks carry 'use client'. The others reach the client through lib/formdef/index.tsx, which carries it as well, so a server component renders a form from createForm like any other component.