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

Changed
Round each purchase up and move the change to savings.
Follows the page theme and corners
'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

Changed
ProviderChanged
Changed
From your provider's dashboard. Kept while logos are off, and never sent.
Follows the page theme and corners
'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

Changed

Last sent Monday at 9:00, to you@example.com.

Follows the page theme and corners
'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 aria-controls. Default from useId, readable through ref.

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.

TabFrom the gate, moves into the fields when they show, and past them when they are hidden.
SpaceOn 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.

esc

↑ ↓ to move↵ to open