Skip to main content

Layout & Styling

UniForm separates field rendering from structural chrome — you can swap out the form wrapper, section wrapper, submit button, and array row layout without touching field components.

LayoutSlots

Pass a layout object to <AutoForm> (or createAutoForm) to override any slot:

SlotDefaultRenders
formWrapperDefaultFormWrapper<form> element + children
sectionWrapperDefaultSectionWrapper<fieldset> + <legend> around grouped fields
submitButtonDefaultSubmitButton<button type="submit">
arrayRowLayoutDefaultArrayRowLayoutRow with add/remove/reorder controls for array fields
loadingFallback<p>Loading…</p>Shown while async defaultValues resolves

Slot prop types

// formWrapper — receives only children; the <form> element is managed by AutoForm
type FormWrapperProps = { children: React.ReactNode }

// sectionWrapper
type SectionWrapperProps = { title: string; children: React.ReactNode }

// submitButton
type SubmitButtonProps = {
isSubmitting: boolean
label: string
}

// arrayRowLayout — buttons are pre-rendered nodes, not callbacks
type ArrayRowLayoutProps = {
children: React.ReactNode
buttons: {
moveUp: React.ReactNode | null // null when already first row
moveDown: React.ReactNode | null // null when already last row
duplicate: React.ReactNode | null // null when at maxItems
remove: React.ReactNode
collapse: React.ReactNode | null // null when collapsible is disabled
}
index: number // zero-based row index
rowCount: number // total number of rows
}

classNames

Add CSS classes to structural elements without replacing the whole component:

<AutoForm
classNames={{
form: 'space-y-6',
fieldWrapper: 'flex flex-col gap-1',
label: 'text-sm font-medium text-gray-700',
error: 'text-xs text-red-600 mt-1',
description: 'text-xs text-gray-500',
section: 'border border-gray-200 rounded-lg p-4',
sectionTitle: 'text-sm font-semibold text-gray-800 mb-3',
}}
...
/>

Field wrapper CSS variables

The default field wrapper sets three CSS custom properties on each field's container element. Use these in your fieldWrapper class to build grid or stacked layouts without a custom wrapper component.

VariableValueDescription
--field-span112Column span from meta.span (or fields[name].span), falling back to 1
--field-index0, 1, 2, …Zero-based render index of the field within its section
--field-depth0, 1, 2, …Nesting depth (0 = top-level, 1 = inside an array row, etc.)

Example — 12-column grid driven entirely by CSS:

.field-wrapper {
grid-column: span var(--field-span);
}
<AutoForm classNames={{ form: 'grid grid-cols-12 gap-4', fieldWrapper: 'field-wrapper' }} ... />

Field wrapper data attributes

The default field wrapper also sets data-* attributes on the container element. These are useful for CSS selectors and for testing.

AttributePresent when
data-field-nameAlways — value is the field's dot-notated name
data-field-typeAlways — value is the resolved type key ("string", "number", "boolean", "date", "select")
data-requiredField is required (not optional/nullable in the schema)
data-disabledField is disabled (via meta.disabled, fields[name].disabled, or the global disabled prop)
data-has-errorA validation error is currently shown on this field
data-has-descriptionThe field has a description set

Example — style required fields and error states with plain CSS:

[data-required]::after {
content: ' *';
color: #dc2626;
}

[data-has-error] label {
color: #dc2626;
}

[data-field-type='boolean'] {
flex-direction: row;
align-items: center;
}

Live Example

A card-style form wrapper with a custom submit button:

Live Editor
// Custom card form wrapper
const CardForm = ({ children }) => (
  <div
    style={{
      background: 'var(--ifm-background-color)',
      border: '1px solid var(--ifm-color-emphasis-300)',
      borderRadius: 12,
      boxShadow: '0 4px 16px rgba(0,0,0,0.07)',
      padding: '1.5rem',
      maxWidth: 420,
    }}
  >
    {children}
  </div>
)

// Custom section with accent bar
const AccentSection = ({ title, children }) => (
  <div
    style={{
      borderLeft: '3px solid #4F46E5',
      paddingLeft: '1rem',
      marginBottom: '1.25rem',
    }}
  >
    <p
      style={{
        fontWeight: 600,
        color: '#4F46E5',
        marginBottom: '0.75rem',
        fontSize: 13,
        textTransform: 'uppercase',
        letterSpacing: 1,
      }}
    >
      {title}
    </p>
    {children}
  </div>
)

// Gradient submit button
const GradientButton = ({ isSubmitting, label }) => (
  <button
    type='submit'
    disabled={isSubmitting}
    style={{
      background: 'linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%)',
      color: '#fff',
      border: 'none',
      borderRadius: 8,
      padding: '10px 24px',
      fontWeight: 600,
      cursor: isSubmitting ? 'not-allowed' : 'pointer',
      opacity: isSubmitting ? 0.6 : 1,
      width: '100%',
    }}
  >
    {isSubmitting ? 'Saving…' : label}
  </button>
)

const schema = z.object({
  fullName: z.string().min(1, 'Required'),
  email: z.string().email(),
  department: z.enum(['engineering', 'design', 'product', 'marketing']),
  startDate: z.string().optional(),
})

const employeeForm = createForm(schema)

function App() {
  const [saved, setSaved] = React.useState(null)
  return (
    <div style={{ fontFamily: 'system-ui', padding: '1rem' }}>
      <AutoForm
        form={employeeForm}
        layout={{
          formWrapper: CardForm,
          sectionWrapper: AccentSection,
          submitButton: GradientButton,
        }}
        fields={{
          fullName: { label: 'Full Name', section: 'Identity' },
          email: { section: 'Identity' },
          department: { section: 'Role' },
          startDate: { label: 'Start Date', section: 'Role' },
        }}
        labels={{ submit: 'Add Employee' }}
        onSubmit={(v) => setSaved(v)}
      />
      {saved && (
        <pre
          style={{
            marginTop: '1rem',
            background: 'var(--ifm-color-emphasis-200)',
            padding: '1rem',
            borderRadius: 6,
          }}
        >
          {JSON.stringify(saved, null, 2)}
        </pre>
      )}
    </div>
  )
}

render(<App />)
Result
Loading...