Buttons
<Form> automatically renders up to three navigation buttons:
| Button | Purpose | When Shown |
|---|---|---|
submit | Submit the form | On the last step |
next | Go to next step | On non-final steps |
back | Return to previous step | When 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:
| Button | Label | Variant |
|---|---|---|
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:
- Per form — pass
labelin.buttons()(shown throughout this page). - Per locale — provide an i18n overlay; see the i18n guide.
- 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
FieldBuilderneeds a schema (the only exception ishiddenfields) — a validation method such as.required(),.optional(), or an explicit.schema()provides one. Without it,addFieldthrows immediately.
Button Properties
Properties applied to navigation buttons
| Property | Type | Default | Description |
|---|---|---|---|
type | 'submit' | 'next' | 'back' | - | Button type (required) |
label | string | English defaults (see above) | Button text |
variant | 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'default'/'outline'/'ghost' per button | Visual style, forwarded to your Button component |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'default' is forwarded when unset | Button size, forwarded to your Button component |
icon | ReactNode | - | 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, andloadingdo work when a button is added as a form field — see Custom Button Actions.effectandroundedare declared in the type but not yet implemented: no render path forwards them to the rendered button, even if your injectedButtoncomponent 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' },
})
| Value | Behavior |
|---|---|
'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
}
Related
- Multi-Step Forms — Step navigation, conditional routing
- i18n — Translating the default button labels per locale