Fields that appear when a switch turns on, and keep what was typed while they are away.
Kept, but not sent
A setting that needs more once it is on, such as bank logos that need a provider and a key, shows those fields under its switch and hides them when the switch turns off. While hidden, the fields keep what the reader typed: nothing clears them, so turning the switch back on brings everything back as it was.
Hidden fields are left out of the form's submission and its checks. A hidden key never overwrites the saved one, and a hidden required field never blocks a save with an error nobody can see. So an absent field means nothing on its own: read the gate on the server to know what it means. With logos off, keep the saved key; with logos on and no key, ask for one.
The group is a fieldset that is hidden, inert and disabled while the gate is off, in the server's HTML too, so before any script runs a hidden field is out of the tab order, the reading order and the submission. Pass submitsWhenHidden for the other model, where hidden fields are still sent and checked and your server ignores them by the gate; then make those fields not required yourself.
The gate
The gate is your own control, usually a switch, placed right before the group, with aria-controls naming the group's id. The fields line up with the switch, with no indent, and appear at once.
A switch or a checkbox passes aria-controls to its input. A radio group or a select takes no aria-controls, so nothing can name the group: it warns once in development, and if it hides while holding focus, focus goes to the control before it, the chosen radio or the select's button. Keep such a gate right before the group so that is the gate.
Example
Example
'use client';
import { ConditionalFields, Switch, TextField } from '@snapshot-ui/react';
import { useId, useState, type ReactElement } from 'react';
export default function ConditionalFieldsBasic(): ReactElement {
const [isOn, setIsOn] = useState(false);
// The gate names the group it shows. Any id works; useId keeps two copies on a page apart.
const id = useId();
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
{/* The gate: the app's own Switch, right before the group, pointing at it. */}
<Switch
isSelected={isOn}
onChange={setIsOn}
aria-controls={id}
description="Round each purchase up and move the change to savings."
>
Round up purchases
</Switch>
<ConditionalFields id={id} isShown={isOn}>
<TextField
label="Savings account"
name="roundUpAccount"
defaultValue="Rainy day"
description="Where the change goes. Kept while round-ups are off."
/>
</ConditionalFields>
</div>
);
}A logo provider
A provider and its secret key under "Show bank logos". Type a key, turn logos off and save: the form sends the gate's state and neither the provider nor the key. Turn logos on again and the key is still there.
Example
Example
'use client';
import { Button, ConditionalFields, PickerOption, Select, Switch, TextField } from '@snapshot-ui/react';
import { useId, useState, type ReactElement } from 'react';
export default function ConditionalFieldsLogo(): ReactElement {
const [logo, setLogo] = useState(true);
const [sent, setSent] = useState<string[] | null>(null);
const id = useId();
return (
<form
className="stack"
style={{ width: '100%', maxWidth: '24rem' }}
onSubmit={(event) => {
event.preventDefault();
// What a server action would receive: with logos off, no provider and no key.
setSent([...new FormData(event.currentTarget).keys()]);
}}
>
{/* The gate sends `logos` only while on, so the server reads it to know what an absent key means. */}
<Switch isSelected={logo} onChange={setLogo} aria-controls={id} name="logos">
Show bank logos
</Switch>
<ConditionalFields id={id} isShown={logo}>
<Select label="Provider" name="logoProvider" defaultValue="northwind">
<PickerOption id="northwind">Northwind Logos</PickerOption>
<PickerOption id="fabrikam">Fabrikam Marks</PickerOption>
</Select>
<TextField
label="API key"
name="logoKey"
type="password"
description="From your provider's dashboard. Kept while logos are off, and never sent."
/>
</ConditionalFields>
<div className="row">
<Button type="submit" variant="secondary">
Save
</Button>
<output>{sent === null ? null : `Sent: ${sent.join(', ')}`}</output>
</div>
</form>
);
}A status and a button
The group can hold no field at all. Under "Weekly email", a status line and a button to send a test: while the email is off, the hidden fieldset also disables the button, which nobody sees.
Example
Example
'use client';
import { Button, ConditionalFields, Switch, Text } from '@snapshot-ui/react';
import { useId, useState, type ReactElement } from 'react';
export default function ConditionalFieldsEmail(): ReactElement {
const [isOn, setIsOn] = useState(true);
const id = useId();
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<Switch isSelected={isOn} onChange={setIsOn} aria-controls={id}>
Weekly email
</Switch>
{/* No field at all: a status line and a button. While hidden, the button is disabled too, unseen. */}
<ConditionalFields id={id} isShown={isOn}>
<Text>Last sent Monday at 9:00, to you@example.com.</Text>
<div>
<Button variant="secondary">Send a test</Button>
</div>
</ConditionalFields>
</div>
);
}Usage
A client component, for its focus move. It renders the group shown or hidden from isShown on the server, and never reads or writes a value.
import { ConditionalFields, Switch, TextField } from '@snapshot-ui/react';
<>
<Switch isSelected={logo} onChange={setLogo} aria-controls="logo-fields" name="logos">
Show bank logos
</Switch>
<ConditionalFields id="logo-fields" isShown={logo}>
<TextField label="API key" name="logoKey" type="password" />
</ConditionalFields>
</>;Space the group as your form spaces its fields with --conditional-fields-gap, which defaults to the peer gap. A changed field keeps its Changed chip while hidden; if your SaveBar counts changes, count only what will be saved, which with the default is what shows.
Props
The rest of a fieldset's attributes go on the fieldset, with className merged after the library's class and ref on the fieldset, where it reads the id.
children ReactNode | The fields that depend on the gate. |
|---|---|
className string | |
id string | The group's id, for the gate's |
isShown boolean | Whether the fields show: the gate's state, the app's. |
submitsWhenHidden boolean | Whether hidden fields still submit with the form and are still checked by it. Hidden fields always keep their values in the page. Default: false |
Accessibility
The gate says its own state, checked or not, and names the group with aria-controls; aria-expanded is not allowed on a switch. Nothing is announced when the group shows: the reader on the gate hears it turn on, and the fields follow it in the reading and tab order. Turning the gate off keeps focus on the gate. If the group hides while focus is in it, because another control or your app turned the gate off, focus goes to the gate; if there is none, or it cannot take focus because your app disabled it or an outer group hid it too, to the control before the group; else to <main>, never the body. Without a name the group has role="none", since the gate before it says what follows; pass aria-label for a named group. In development, a group no gate points at warns once.
| Tab | From the gate, moves into the fields when they show, and past them when they are hidden. |
|---|---|
| Space | On the gate's switch, shows or hides the fields; focus stays on the switch. |
Design rules
What keeps conditional fields in the system.
Put the gate right before the group, and point its aria-controls at it.
Read the gate on the server to know what an absent field means.
Line the fields up with the gate.
Never indent the fields or draw a line beside them.
Never animate the fields in or out.
Never dim hidden fields in place. Hide them, or keep them shown.