Forms
Guidelines for building accessible, consistent forms covering layout, validation, and submission
How you design forms affects whether people can actually use them. This page covers everything from layout and spacing to validation and submission, so your forms stay consistent, accessible, and easy to maintain.
Layout & Structure
Layout and structure determine whether a form feels easy or overwhelming. Good spacing and a clear visual hierarchy help people move through a form without getting lost.
Overall form layout
Left-align your forms to match natural reading flow. Keep the width at 800px max (use the container spacing token) so nothing gets too wide to read comfortably.
Whitespace matters more than most people think. Enough breathing room between sections and fields keeps the form scannable and prevents everything from blurring together. Use our spacing tokens to keep that whitespace consistent.
Do
Align form content to the left for better scanning and organization.
Use a maximum width of 800px (spacing token
container) for forms to ensure readability across devices.Ensure sufficient whitespace around and within the form for a clear visual hierarchy.
Group related fields together to provide more context and make the UI easier to scan.
Available components
See the Form Fields page for a full guide on input components.
Beyond those, you'll likely need some of these to put a form together:
- Layout components:
<Stack>and<Inset>to organize fields and sections. See Layouts for choosing the right layout component (and why fields never belong in a<Table>). - Accordion: Group fields with an accordion for lengthy forms or optional fields. Use accordions for sections that can be collapsed to save space and reduce cognitive load.
- Buttons: Use primary buttons for form submission, secondary for additional actions like "View PDF".
- Links: Use links for navigation to privacy policies or terms of service, not for form actions.
- Feedback components:
<SectionMessage>and<Toast>for inline hints or notifications.
Content Organization
Forms have three levels of hierarchy: fields are the individual inputs, sections divide the form into topics, and groups cluster related fields within a section. Spacing signals how closely things belong together. Tighter spacing means a stronger relationship, wider spacing separates them.
Want to learn more about spacing?
Visit the Spacing page for a full overview of our spacing tokens and how to use them.
Field
Fields are the most fundamental building block of any form. How you arrange and size them affects how easily people can work through it.
- Use a predictable order. People expect forms to follow a logical sequence, like personal info before payment or name before email. A familiar order reduces cognitive load because users don't have to search for what comes next. When in doubt, follow the conventions people already know from similar forms.
- Place required fields before optional ones. Within each section, lead with what the user must fill in. This lets people complete the essentials first and decide later whether to provide optional details. It also prevents frustration: nobody wants to discover required fields buried at the bottom after filling in optional ones.
- Position dependent fields right after their parent. If selecting a country changes the list of available states, those two fields should sit next to each other. Placing dependent fields immediately after the field they depend on makes the relationship obvious and prevents confusion when values change.
- Match field width to expected content. A postal code field doesn't need the same width as a street address. Setting a maximum width based on the expected input length gives users a visual hint about what's expected and keeps the form looking tidy.
Fields stack vertically by default, but some belong side by side. Two spacing tokens handle this:
regular: Vertical spacing between stacked fields. Wrap fields in<Stack space="regular">within any section or group.related: Horizontal spacing for fields that belong together, like postcode + city or start/end dates. Place them side by side with<Inline space="related">.
Shipping Address
Section
Sections split a form by topic so longer forms stay scannable. They pay off once a form collects different kinds of data or grows past 6-8 fields.
Separate sections with <Stack space="section">. Inside each section, add a headline and wrap the fields in a <Stack space="regular">.
Section headlines can have a short description underneath. Use <Text> with the muted variant for this, and wrap both in a <Stack space="tight"> so they feel connected.
Personal Information
Account Details
Group
Groups are sub-clusters of related fields within a section. Use them when a section contains many fields that benefit from additional visual separation, for example an address block within a personal information section.
To create a group, wrap the fields with <Stack space="group"> to add spacing that visually separates the group from surrounding fields. Inside the group, use <Stack space="regular"> to space the fields as usual. The group token only controls the separation around the group, not the spacing between fields within it.
Personal Information
Address
Panels
For settings pages and forms with independent sections, use <Panel> instead of plain sections. Panels provide a structured container with a header, description, content area, optional collapsible section, and a footer for actions.
This pattern works well when each section can be saved independently. Wrap the <Form> around the entire <Panel> so that all fields, including those inside <Panel.Collapsible>, are part of the submission.
Use <Panel.Collapsible> for advanced or rarely-used fields that should be accessible but not visible by default. Unlike accordions, collapsible panel sections are tied to a specific panel and don't compete with other sections for attention.
For destructive actions, use <Panel variant="destructive"> to visually separate irreversible operations from regular settings.
Accordions
Accordions can replace regular sections to create collapsible groups of fields. This is useful for optional information or advanced settings that don't need to be visible by default. Ensure required fields remain visible and are not hidden within collapsed sections.
Accordions can also be used within a section to group content. This lets users focus on what matters while still having access to additional options. The demo below shows both patterns.
Event Details
Location
'use client';import { useState } from 'react';import { Accordion, Checkbox, DatePicker, Headline, Inline, Inset, NumberField, Select, Stack, Switch, TextArea, TextField,} from '@marigold/components';import { VisualSpacing } from '@/ui/VisualSpacing';export default () => { const [showSpacing, setShowSpacing] = useState(false); return ( <Stack space="8"> <Switch label="Show spacing" selected={showSpacing} onChange={setShowSpacing} /> <Inset px={20}> <Stack space="section"> <Stack space="regular"> <Headline level={2}>Event Details</Headline> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <TextField label="Event Name" required /> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <TextArea label="Description" description="What is this event about?" rows={3} required /> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <div className={showSpacing ? 'pb-8' : ''}> <Inline space="related"> <DatePicker label="Start Date" width="fit" required /> {showSpacing && ( <VisualSpacing space="related" orientation="horizontal" /> )} <DatePicker label="End Date" width="fit" /> </Inline> </div> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <Accordion variant="card"> <Accordion.Item id="advanced-settings"> <Accordion.Header>Advanced Event Settings</Accordion.Header> <Accordion.Content> <Stack space="regular"> <Select label="Event Category" placeholder="Select category" width="1/2" > <Select.Option id="conference">Conference</Select.Option> <Select.Option id="workshop">Workshop</Select.Option> <Select.Option id="meetup">Meetup</Select.Option> <Select.Option id="webinar">Webinar</Select.Option> </Select> <NumberField label="Maximum Attendees" description="Leave empty for unlimited" width={44} /> <TextField label="Event Code" description="Used for registration" width="fit" /> <Checkbox label="Require registration approval" /> <Checkbox label="Send confirmation emails" /> </Stack> </Accordion.Content> </Accordion.Item> </Accordion> </Stack> {showSpacing && ( <VisualSpacing orientation="vertical" space="section" /> )} <Stack space="regular"> <Headline level={2}>Location</Headline> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <TextField label="Venue Name" required /> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <TextField label="Address" required /> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <div className={showSpacing ? 'pb-8' : ''}> <Inline space="related"> <TextField label="Postal Code" width={20} /> {showSpacing && ( <VisualSpacing space="related" orientation="horizontal" /> )} <TextField label="City" required width="1/2" /> </Inline> </div> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <Accordion variant="card"> <Accordion.Item id="location-details"> <Accordion.Header>Additional Location Details</Accordion.Header> <Accordion.Content> <Stack space="regular"> <Inline space="related" alignY="input"> <TextField label="Floor" width={10} /> <TextField label="Room Number" width={16} /> </Inline> <TextArea label="Directions" description="How to find the venue" rows={3} /> <Checkbox label="Wheelchair accessible" /> <Checkbox label="Parking available" /> </Stack> </Accordion.Content> </Accordion.Item> </Accordion> </Stack> {showSpacing && ( <VisualSpacing orientation="vertical" space="section" /> )} <Stack space="regular"> <Accordion variant="card"> <Accordion.Item id="contact-info"> <Accordion.Header>Contact Information</Accordion.Header> <Accordion.Content> <Stack space="regular"> <TextField label="Organizer Name" width="fit" /> <TextField label="Email" type="email" width="fit" /> <TextField label="Phone" type="tel" width="fit" /> <Checkbox label="Display contact info publicly" /> </Stack> </Accordion.Content> </Accordion.Item> </Accordion> {showSpacing && ( <VisualSpacing orientation="vertical" space="regular" /> )} <Accordion variant="card"> <Accordion.Item id="special-requirements"> <Accordion.Header>Special Requirements</Accordion.Header> <Accordion.Content> <Stack space="regular"> <Checkbox label="Catering required" /> <Checkbox label="Audio/Visual equipment needed" /> <Checkbox label="Security required" /> <TextArea label="Additional Notes" rows={3} /> </Stack> </Accordion.Content> </Accordion.Item> </Accordion> </Stack> </Stack> </Inset> </Stack> );};Actions
Forms usually need more than just a submit button. Links, secondary buttons, and other controls handle everything from navigation to drafts. The key is making each action easy to find and hard to confuse with the primary submit.
Action hierarchy
A normal form has exactly one primary action: the submit. Everything else steps down the hierarchy so the primary action stays unmistakable. Giving two actions the primary variant makes them compete for attention and leaves people unsure which one completes the form.
Map each action to a Button variant by its role:
| Action role | Use |
|---|---|
| Submit the form (the one primary action) | primary |
| Supporting form operation ("Save as Draft", "Reset") | secondary |
| Low-emphasis action that should stay quiet | ghost |
| Destructive form action ("Delete account") | destructive/destructive-ghost |
| Navigation away from the form (terms, privacy policy) | <Link>/<LinkButton> |
This is the same cascade the Button component describes. See Visual hierarchy for why a section gets only one primary action, Placement and order for how the primary → secondary → ghost variants line up, and Destructive buttons for when a form action carries irreversible consequences.
Settings pages are the exception
Settings pages are the one place where a single page carries more than one
primary action. When a page is split into independently-saving <Panel>s,
each panel is its own form and owns its own primary save button — so a
multi-panel settings page has one primary action per panel, not per page.
Never share one submit across panels: a save in one panel must not commit the
fields of another. Outside of this case, keep it to one primary action per
form.
Inline actions
Some actions belong inside the form body, next to the fields they relate to.
- Links for navigation-like actions. Opening a map, viewing a PDF, or linking to terms of service. These aren't form operations, so style them as links. Place them near the field they support.
- Secondary buttons for form operations. "Create new", "Reset", or "Save as Draft" should be visually distinct from the primary submit so people don't hit the wrong one.
- Put each action close to what it affects. An action buried at the bottom of the form is easy to miss when it only matters for one specific field.
Venue
Submit placement
Put the submit button at the bottom of the form, aligned left. The primary button stays put. Other actions arrange themselves around it.
Where you place secondary actions depends on what they do:
- "Save as Draft" and similar alternative completions go beside the primary button, styled as secondary.
- "Cancel" also sits beside the primary button, but always after it so the most important action comes first in reading and tab order.
Admin- and Mastermark
Some fields are only relevant for administrators or master users. Mark and separate these from regular fields so other users aren't confused by them.
See the Admin- and Mastermark page for the full usage guide.
Workflow forms
Workflow forms guide the user through a single goal from start to finish. Everything submits together, and once submitted, the user is done.
Use them when the user has come to accomplish something: creating a thing, signing up, registering, checking out, or editing a single record.
- One
<Form>wraps the whole page, or sits inside a<Panel>if you want a structured container with a header, description, and footer. - Sections are plain headings separated with
<Stack space="group">. - A single primary submit at the bottom (see Submit placement).
- Optional fields can hide behind an accordion or a
<Panel.Collapsible>.
The event registration demo puts these together: sections, field types, and a single submit.
Settings forms
Use for configuration that's edited over time, not completed in one go: account preferences, organization defaults, notification rules. Both variants below share the same idea: the save boundary should match the smallest unit the user thinks of as one thing. For batched, reviewable settings that's a panel. For low-stakes toggles it's the individual control.
Save on submit
Use when changes should be batched and explicitly committed: organization defaults, billing details, anything with cross-field validation or destructive consequences. Users can review their edits before saving and back out by not submitting.
- Each section is its own
<Panel>with its own<Form>and a save button in<Panel.Footer>(see Panels). Never share one submit across panels. Saving notifications should never risk committing an unrelated change to billing. <Tabs>group related panels when settings span multiple topics. A single tab can hold one panel or several stacked panels. Each still saves on its own.<Panel.Collapsible>keeps advanced options accessible without cluttering the default view.- Destructive actions live in a separate
<Panel variant="destructive">(a "danger zone") and useConfirmationDialog.
The settings demo shows the full pattern with tabs, panels, and a danger zone.
Auto-save
Use for low-stakes preferences where a saved/unsaved state would feel like friction: notification toggles, privacy switches, single-select preferences. Each control persists the moment the user changes it.
- No
<Form>wrapper, no submit button, no<Panel.Footer>. Each<Panel>is just a structured container around the controls. - Each control handles its own persistence via
onChange. - Set the user's expectation up front with a description like "Changes save automatically" near the page heading.
- Stay silent on success. The control's selected state is the confirmation, so a "Saved" toast on every change is noise. Surface only failures, persistently (toast or inline near the control), so the user can retry.
- Stick to discrete controls (Switch, Checkbox, Radio, Select). For text fields, sliders, or anything continuous, the visual state alone doesn't confirm the save. Use save on submit, or extend the pattern with a debounced "Saving…/Saved" indicator near the field.
- Avoid for fields that need cross-field validation, a confirmation step, or any change that's costly to reverse. Use save on submit instead.
Validation
Validation keeps the data you collect accurate and tells users how to fix what's wrong before they hit submit. Marigold's form components plug into the browser's constraint validation API, so most of what you need comes from the platform. When that isn't enough, you can layer in custom logic, real-time feedback, server responses, or a schema library like Zod.
Built-in validation
The simplest way to validate is to use native HTML constraints like required, type="email", minLength, and pattern. The browser checks them on blur or on submit, and Marigold styles the messages to match the rest of the design instead of using the browser's defaults.
Here is an email subscription form. If you submit it without entering an email address or if the entered email is invalid, an error will be displayed:
import { Button, Description, Form, Panel, TextField, Title,} from '@marigold/components';export default () => { return ( <Form> <Panel size="form"> <Panel.Header> <Title>Subscribe to our Newsletter</Title> <Description> Stay updated with our latest news and updates. </Description> </Panel.Header> <Panel.Content> <TextField label="Email Address" name="email" type="email" placeholder="Enter your email address" required /> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Subscribe </Button> </Panel.Footer> </Panel> </Form> );};Custom messages
Browser-provided messages are useful but generic. Pass a function to errorMessages to swap in your own copy. You only need to override the cases you care about. Anything you don't return falls back to the browser default.
import { Button, Description, Form, Panel, TextField, Title,} from '@marigold/components';export default () => { return ( <Form> <Panel size="form"> <Panel.Header> <Title>Subscribe to our Newsletter</Title> <Description> Stay updated with our latest news and updates. </Description> </Panel.Header> <Panel.Content> <TextField label="Email Address" name="email" type="email" placeholder="Enter your email address" required errorMessage={({ validationDetails }) => validationDetails.valueMissing ? 'Please enter your email address!' : '' } /> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Subscribe </Button> </Panel.Footer> </Panel> </Form> );};Custom validation
When native constraints aren't expressive enough, pass a function to the field's validate prop. It returns one error message, or an array if you have several. The custom result replaces the native one entirely, so handle the empty case yourself when needed.
import { Button, Description, Form, Panel, TextField, Title,} from '@marigold/components';export default () => { return ( <Form> <Panel size="form"> <Panel.Header> <Title>Subscribe to our Newsletter</Title> <Description> Stay updated with our latest news and updates. </Description> </Panel.Header> <Panel.Content> <TextField label="Email Address" name="email" type="email" placeholder="Enter your email address" required validate={val => val.length && /^\S+@\S+\.\S+$/.test(val) ? '' : 'Please enter a valid email address!' } /> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Subscribe </Button> </Panel.Footer> </Panel> </Form> );};Real-time validation
By default, errors appear after the user leaves the field (on blur) or submits the form. That delay is deliberate: it prevents users from seeing irrelevant errors while they're still in the middle of typing a value.
Real-time validation makes sense in narrow situations like password rules, where users benefit from seeing requirements light up as they type. Control the field and set error and errorMessages yourself.
Choose a password
import { useState } from 'react';import { Panel, TextField, Title } from '@marigold/components';export default () => { const [password, setPassword] = useState(''); const [dirty, setDirty] = useState(false); const errors = []; if (password.length < 8) { errors.push('Password must be 8 characters or more.'); } if ((password.match(/[A-Z]/g) ?? []).length < 2) { errors.push('Password must include at least 2 upper case letters'); } if ((password.match(/[^a-z]/gi) ?? []).length < 2) { errors.push('Password must include at least 2 symbols.'); } const showErrors = dirty && errors.length > 0; return ( <Panel size="form"> <Panel.Header> <Title>Choose a password</Title> </Panel.Header> <Panel.Content> <TextField label="Password" value={password} onChange={value => { setPassword(value); setDirty(true); }} error={showErrors} errorMessage={showErrors ? errors : undefined} /> </Panel.Content> </Panel> );};Server errors
Client-side validation is for fast feedback. Server-side validation is what actually protects data. Pass server errors back through the validationErrors prop on <Form>. Provide an object keyed by field name, where each value is either a string or an array of strings if a field has more than one error. The errors appear immediately and clear themselves when the user edits the field.
In the demo, submitting support@reservix.de simulates a server-side rejection.
import { QueryClient, QueryClientProvider, useMutation,} from '@tanstack/react-query';import type { FormEvent } from 'react';import { Button, Description, Form, Inline, Panel, TextField, Title,} from '@marigold/components';import { Check } from '@marigold/icons';interface ValidationError extends Error { cause?: { [name: string]: string[] };}// Simulates a server response. In real code this would be a `fetch` call.const subscribeRequest = async (email: string) => { await new Promise(resolve => setTimeout(resolve, 500)); if (email === 'support@reservix.de') { throw new Error('Invalid user inputs', { cause: { email: ['This email is already subscribed.'] }, }); } return { ok: true };};const SuccessMessage = () => ( <Inline alignY="center" space={1}> <Check color="text-success" size="12" /> Successfully subscribed! </Inline>);const App = () => { /** * Server communication * * (We are using `@tanstack/react-query` in this example to interact * with a server. Regular form request via the `action` attribute work too!) */ const mutation = useMutation<unknown, ValidationError, string>({ mutationFn: subscribeRequest, }); // Form handling const subscribe = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); const email = new FormData(e.currentTarget).get('email') as string; mutation.mutate(email); }; // Show form errors from server const validationErrors = mutation.error ? mutation.error.cause : undefined; return ( <Form onSubmit={subscribe} validationErrors={validationErrors}> <Panel size="form"> <Panel.Header> <Title>Subscribe to our Newsletter</Title> <Description> Stay updated with our latest news and updates. </Description> </Panel.Header> <Panel.Content> <TextField label="Email Address" name="email" type="email" placeholder="Enter your email address" description={mutation.isSuccess && <SuccessMessage />} required /> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit" loading={mutation.isPending}> Subscribe </Button> </Panel.Footer> </Panel> </Form> );};const queryClient = new QueryClient();export default () => ( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider>);Wiring up mutations
This demo uses useMutation to talk to the server. For the broader picture of
fetching, mutating and giving feedback with React Query, see the Fetching and
Mutations pattern.
Validation with Zod
For complex forms with nested objects, conditional fields, or cross-field rules, Zod keeps validation declarative. Define the schema once, infer the TypeScript type from it, and run safeParse on the form data before submit.
import { FormEventHandler, useState } from 'react';import { z } from 'zod';import { zfd } from 'zod-form-data';import { Button, Checkbox, Columns, Panel, Select, Stack, TextField, Title,} from '@marigold/components';const schema = z.object({ firstname: z.string().min(1, 'Please enter your firstname.'), name: z.string().min(1, 'Please enter your name.'), phone: z.string().min(6, 'Please enter a valid phone number.'), mail: z.string().email('Please enter a valid e-mail address.'), country: z.string().min(1, 'Please select a country.'), terms: zfd.checkbox().refine(v => v === true, 'You must agree to the terms.'),});type FieldName = keyof z.infer<typeof schema>;export default () => { const [errors, setErrors] = useState<Partial<Record<FieldName, string>>>({}); const handleSubmit: FormEventHandler<HTMLFormElement> = e => { e.preventDefault(); const data = Object.fromEntries(new FormData(e.currentTarget)); const result = schema.safeParse(data); if (!result.success) { const fieldErrors: Partial<Record<FieldName, string>> = {}; for (const issue of result.error.issues) { const field = issue.path[0] as FieldName; if (!fieldErrors[field]) fieldErrors[field] = issue.message; } setErrors(fieldErrors); return; } setErrors({}); alert(JSON.stringify(data)); }; return ( <form onSubmit={handleSubmit} noValidate> <Panel size="form"> <Panel.Header> <Title>Account Registration</Title> </Panel.Header> <Panel.Content> <Stack space={4}> <Columns columns={[2, 2]} space={4}> <TextField name="firstname" label="Firstname" description="Please enter your first name." placeholder="Firstname" error={!!errors.firstname} errorMessage={errors.firstname} /> <TextField name="name" label="Name" description="Please enter your name." placeholder="Name" error={!!errors.name} errorMessage={errors.name} /> </Columns> <TextField name="phone" label="Phone" type="tel" placeholder="Phone" description="Please enter your phone number." error={!!errors.phone} errorMessage={errors.phone} /> <TextField name="mail" label="E-Mail" placeholder="E-Mail" description="Please enter your e-mail address." error={!!errors.mail} errorMessage={errors.mail} /> <Select name="country" label="Country" placeholder="Select a country..." description="Please select your country." error={!!errors.country} errorMessage={errors.country} > <Select.Option key="germany">Germany</Select.Option> <Select.Option key="austria">Austria</Select.Option> <Select.Option key="switzerland">Switzerland</Select.Option> </Select> <Checkbox name="terms" label="Agree to the terms" error={!!errors.terms} description={errors.terms} /> </Stack> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Submit </Button> </Panel.Footer> </Panel> </form> );};State Management & Submission
Forms need to capture user input, react to it, and hand it off when the user submits. Marigold's form components work with both uncontrolled and controlled patterns, and integrate cleanly with native FormData and React 19 actions.
Controlled vs uncontrolled
Uncontrolled fields manage their own value. You read it back from the DOM (or FormData) at submit time. Use defaultValue to set an initial value. This is the simpler default: fewer renders, less code.
Controlled fields hold their value in React state via value and onChange. Reach for control when the value needs to drive something else: dependent fields, real-time validation, formatting as the user types.
Submitting with FormData
The simplest way to read a form is the browser's FormData API. Wrap your fields in <Form> and reach for parseFormData, a Marigold utility that converts the submitted form into a plain JavaScript object you can pass straight to fetch or hand off to a validation library. It also handles repeated field names (like grouped checkboxes) by collecting them into an array.
Names matter
Every field that should be submitted needs a name prop. Without one, the
value won't appear in FormData or in the submitted payload.
import { type FormEvent, useState } from 'react';import { Button, Form, Panel, Stack, TextField, Title, parseFormData,} from '@marigold/components';export default () => { const [data, setData] = useState<Record<string, unknown> | null>(null); const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setData(parseFormData(e)); }; return ( <Stack space={4}> <Form onSubmit={handleSubmit}> <Panel size="form"> <Panel.Header> <Title>Apply promo code</Title> </Panel.Header> <Panel.Content> <TextField label="Promo Code" name="promocode" width={44} /> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Submit </Button> </Panel.Footer> </Panel> </Form> {data && ( <pre> <code>{JSON.stringify(data, null, 2)}</code> </pre> )} </Stack> );};Conditional fields
Some fields only make sense after another field is set. For example, toggle "Other reason" when "Other" is selected, or show a tax ID only for business accounts. Control the parent field, read its value, and render the dependent field conditionally.
Cancel subscription
import { Key, useState } from 'react';import { Button, Panel, Select, Stack, TextArea, Title,} from '@marigold/components';export default () => { const [reason, setReason] = useState<Key | null>(null); return ( <Panel size="form"> <Panel.Header> <Title>Cancel subscription</Title> </Panel.Header> <Panel.Content> <Stack space={4}> <Select label="Reason" placeholder="Select a reason..." description="Help us improve by telling us why you're leaving." onChange={setReason} > <Select.Option id="price">Too expensive</Select.Option> <Select.Option id="missing-feature"> Missing a feature </Select.Option> <Select.Option id="not-using">Not using it enough</Select.Option> <Select.Option id="other">Other</Select.Option> </Select> {reason === 'other' && ( <TextArea label="Tell us more" rows={3} required /> )} </Stack> </Panel.Content> <Panel.Footer> <Button variant="primary" type="submit"> Cancel subscription </Button> </Panel.Footer> </Panel> );};Async forms with useActionState
In React 19, async submissions belong in actions. useActionState tracks the pending state for you and returns the action's result, so you don't need a separate useState for "is submitting" or "what did the server say".
import { useActionState } from 'react';import { Button, Form, Panel, Stack, TextField, Title,} from '@marigold/components';type FormState = { error?: string; success?: string;};const INITIAL_STATE: FormState = { error: '', success: '',};export default () => { const [{ error, success }, submitAction, isPending] = useActionState< FormState, FormData >(async (_previousState, formData) => { const name = formData.get('name') as string; const email = formData.get('email') as string; if (Math.random() > 0.5) { return { ...INITIAL_STATE, error: 'An error occurred. Please try again.', }; } // Simulate async action (e.g., API call) await new Promise(resolve => setTimeout(resolve, 2000)); return { ...INITIAL_STATE, success: `You searched successfully for ${name} and ${email}`, }; }, INITIAL_STATE); return ( <Form action={submitAction}> <Panel size="form"> <Panel.Header> <Title>User Search</Title> </Panel.Header> <Panel.Content> <Stack space={4}> <TextField type="text" name="name" label="Name" placeholder="Name" required errorMessage={({ validationDetails }) => validationDetails.valueMissing ? 'Please enter a name!' : '' } /> <TextField type="email" name="email" label="Email" placeholder="Email" required /> {success && <p>{success}</p>} {error && <p className="text-red-600">{error}</p>} </Stack> </Panel.Content> <Panel.Footer> <Button type="submit" loading={isPending} variant="primary"> Search </Button> </Panel.Footer> </Panel> </Form> );};