Buttons

Configure submit, next, and back buttons for your forms.

Buttons

<Form> automatically renders up to three navigation buttons:

ButtonPurposeWhen Shown
submitSubmit the formOn the last step
nextGo to next stepOn non-final steps
backReturn to previous stepWhen there’s step history

Their click behavior is managed by the form: back returns to the previous step, next validates the current step and advances, and submit validates and submits. All three are disabled while a submission is in flight.


Default Buttons

You don’t have to configure anything — when .buttons() is omitted (or only partially set), each button falls back to these defaults:

ButtonLabelVariant
submit'Submit''default'
next'Next →''outline'
back'← Back''ghost'

The default labels come from the package’s built-in English strings (while a submission is in flight, the submit button additionally shows 'Submitting...'). There are three ways to override the labels:

  1. Per form — pass label in .buttons() (shown throughout this page).
  2. Per locale — provide an i18n overlay; see the i18n guide.
  3. Globally — swap all package defaults at once with setDefaultMessages (Spanish apps: setDefaultMessages(es)); see the i18n guide.
// i18n overlay: translate the default buttons per locale
const config = {
  ...baseConfig, // a FormConfig, e.g. from FormBuilder.create(...).build()
  locale: 'es',
  i18n: {
    translations: {
      es: {
        buttons: {
          submit: { label: 'Enviar' },
          next: { label: 'Siguiente →' },
          back: { label: '← Atrás' },
        },
      },
    },
  },
};

Basic Configuration

const config = FormBuilder.create('wizard')
  .addField('name', (f) => f.type('text').label('Name').required())
  .addField('email', (f) => f.type('email').label('Email').email().required())
  .addStep('step1', ['name'])
  .addStep('step2', ['email'])
  .buttons({
    submit: {
      type: 'submit',
      label: 'Send',
      variant: 'default',
    },
    next: {
      type: 'next',
      label: 'Continue',
      variant: 'default',
    },
    back: {
      type: 'back',
      label: 'Back',
      variant: 'outline',
    },
  })
  .build();

Every field built with FieldBuilder needs a schema (the only exception is hidden fields) — a validation method such as .required(), .optional(), or an explicit .schema() provides one. Without it, addField throws immediately.


Button Properties

Properties applied to navigation buttons

PropertyTypeDefaultDescription
type'submit' | 'next' | 'back'-Button type (required)
labelstringEnglish defaults (see above)Button text
variant'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link''default'/'outline'/'ghost' per buttonVisual style, forwarded to your Button component
size'xs' | 'sm' | 'md' | 'lg' | 'xl''default' is forwarded when unsetButton size, forwarded to your Button component
iconReactNode-Icon element (placement is automatic)

Icon placement on navigation buttons is fixed: the back icon renders before the label; next/submit icons render after the label. iconPosition has no effect on them.

Properties NOT applied to navigation buttons

ButtonConfig also declares iconPosition, action, loading, disabled, effect, and rounded, but the navigation buttons rendered by <Form> ignore them:

  • Click handling, disabled state, and loading state are managed by the form itself.
  • iconPosition (default 'left'), action, and loading do work when a button is added as a form field — see Custom Button Actions.
  • effect and rounded are declared in the type but not yet implemented: no render path forwards them to the rendered button, even if your injected Button component supports them.

Variants

The variant string is forwarded to the Button component you inject, so the exact look depends on your component. With a shadcn/ui-style Button:

variant: 'default'; // Primary solid background
variant: 'secondary'; // Secondary muted background
variant: 'outline'; // Border only
variant: 'ghost'; // No background, subtle hover
variant: 'destructive'; // Red/danger style
variant: 'link'; // Looks like a link

Sizes

size is forwarded as-is to your Button component:

size: 'xs'; // Extra small
size: 'sm'; // Small
size: 'md'; // Medium
size: 'lg'; // Large
size: 'xl'; // Extra large

When size is unset, the literal string 'default' is forwarded — shadcn/ui-style Button components treat that as their standard size.


Button Icons

Add icons to buttons using React elements. Placement on navigation buttons is automatic:

import { ArrowRight, ArrowLeft, Send } from 'lucide-react';

.buttons({
  submit: {
    type: 'submit',
    label: 'Send Message',
    icon: <Send className="h-4 w-4" />, // rendered after the label
  },
  next: {
    type: 'next',
    label: 'Continue',
    icon: <ArrowRight className="h-4 w-4" />, // rendered after the label
  },
  back: {
    type: 'back',
    label: 'Go Back',
    icon: <ArrowLeft className="h-4 w-4" />, // rendered before the label
  },
})

Button Alignment

Control horizontal alignment of the button container with align (default: 'end'):

.buttons({
  align: 'between',
  submit: { type: 'submit', label: 'Send' },
})
ValueBehavior
'start'Left aligned
'center'Center aligned
'end'Right aligned (default)
'between'Space between (back left, next/submit right)
'responsive'Stacked on mobile, right-aligned row on desktop

Passing any other value makes .buttons() throw with [@saastro/forms] Invalid button align value: ....

Visual Examples

align: 'start'
[Back] [Submit]

align: 'center'
              [Back] [Submit]

align: 'end'
                              [Back] [Submit]

align: 'between'
[Back]                              [Submit]

align: 'responsive' (mobile)
[Back]
[Submit]

align: 'responsive' (desktop)
                              [Back] [Submit]

Inline Buttons

Place buttons on the same row as fields (useful for search forms):

const config = FormBuilder.create('search')
  .layout('manual')
  .columns(12)
  .addField('query', (f) =>
    f
      .type('text')
      .label('Search')
      .hideLabel()
      .placeholder('Search...')
      .columns({ default: 10 })
      .optional(),
  )
  .addStep('main', ['query'])
  .buttons({
    inline: true, // Same row as fields
    submit: {
      type: 'submit',
      label: 'Search',
    },
  })
  .build();

Multi-Step Form Example

Complete example with navigation buttons:

import { FormBuilder } from '@saastro/forms';
import { ArrowRight, ArrowLeft, Check } from 'lucide-react';

const config = FormBuilder.create('onboarding')
  .layout('manual')
  .columns(12)

  // Step 1: Personal Info
  .addField('name', (f) =>
    f.type('text').label('Full Name').required().columns({ default: 12, md: 6 }),
  )
  .addField('email', (f) =>
    f.type('email').label('Email').email().required().columns({ default: 12, md: 6 }),
  )

  // Step 2: Preferences
  .addField('theme', (f) =>
    f
      .type('select')
      .label('Theme')
      .options([
        { value: 'light', label: 'Light' },
        { value: 'dark', label: 'Dark' },
        { value: 'system', label: 'System' },
      ])
      .required(),
  )

  // Step 3: Confirmation
  .addField('terms', (f) =>
    // .mustBeTrue() (not .required()) is what forces a checkbox to be checked
    f.type('checkbox').label('I accept the terms and conditions').mustBeTrue(),
  )

  // Define steps
  .addStep('personal', ['name', 'email'])
  .addStep('preferences', ['theme'])
  .addStep('confirmation', ['terms'])
  .initialStep('personal')

  // Configure buttons
  .buttons({
    align: 'between',
    submit: {
      type: 'submit',
      label: 'Complete',
      icon: <Check className="h-4 w-4" />,
      variant: 'default',
    },
    next: {
      type: 'next',
      label: 'Continue',
      icon: <ArrowRight className="h-4 w-4" />,
      variant: 'default',
    },
    back: {
      type: 'back',
      label: 'Previous',
      icon: <ArrowLeft className="h-4 w-4" />,
      variant: 'outline',
    },
  })

  .build();

Custom Button Actions

The navigation buttons’ click behavior is fixed — an action set in .buttons() is ignored by <Form>. To render a button with a custom action, add it as a field instead (ButtonConfig is part of the field union). Use .addFields() with a raw config, since action is a function and has no dedicated builder method:

const config = FormBuilder.create('with-cancel')
  .addField('email', (f) => f.type('email').label('Email').email().required())
  .addFields({
    cancel: {
      type: 'button',
      label: 'Cancel',
      variant: 'ghost',
      action: () => {
        if (confirm('Are you sure you want to cancel?')) {
          window.location.href = '/';
        }
      },
    },
  })
  .addStep('main', ['email', 'cancel'])
  .build();

Button fields honor action, iconPosition (default 'left'), and loading (shows a loading state and disables the button), in addition to label, variant, size, and icon. Unlike navigation buttons, a button field with no size set receives 'md' (not the literal 'default').


API Reference

FormButtons Interface

interface FormButtons {
  submit?: ButtonConfig;
  next?: ButtonConfig;
  back?: ButtonConfig;
  align?: 'start' | 'center' | 'end' | 'between' | 'responsive';
  inline?: boolean;
}

ButtonConfig Interface

interface ButtonConfig extends BaseFieldProps {
  type: 'button' | 'submit' | 'next' | 'back';
  label?: string;
  action?: () => void; // button fields only — ignored on navigation buttons
  loading?: boolean; // button fields only — ignored on navigation buttons
  icon?: ReactNode;
  iconPosition?: 'left' | 'right'; // button fields only (default 'left');
  // navigation buttons place icons automatically
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  effect?: string; // declared but not yet implemented — never forwarded
  rounded?: string; // declared but not yet implemented — never forwarded
}

  • Multi-Step Forms — Step navigation, conditional routing
  • i18n — Translating the default button labels per locale