Fliin
On this page

Components / forms

Fieldset

Gathers fields that belong together under a title, such as an address or a profile: legend, the group's description or error, spaced fields and action buttons.

16 of 16 variants pass
Fieldset, described state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add fieldsetAlso installs: core, field

Examples

Described

A fieldset groups related fields under a legend and its description, with the actions that save them below.

Fieldset, described state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/fieldset/fl_fieldset.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

class FieldsetDescribedExample extends StatelessWidget {
  const FieldsetDescribedExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlFieldset(
      legend: 'Profile settings',
      description: 'Update your profile information.',
      actions: [
        FlButton(label: 'Save changes', leading: FlIcon(FlIcons.check), onPressed: () {}),
        FlButton(label: 'Cancel', variant: FlButtonVariant.tertiary, onPressed: () {}),
      ],
      children: const [
        FlTextField(label: 'Name', placeholder: 'John Doe', required: true),
        FlTextField(label: 'Email', placeholder: '[email protected]', required: true),
      ],
    );
  }
}

Error

error describes what is wrong with the group as a whole, when no single field is to blame.

Fieldset, error state
import 'package:your_app/ui/fliin/components/fieldset/fl_fieldset.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

class FieldsetErrorExample extends StatelessWidget {
  const FieldsetErrorExample({super.key});

  @override
  Widget build(BuildContext context) {
    return const FlFieldset(
      legend: 'Shipping',
      error: 'Enter a shipping address.',
      children: [
        FlTextField(label: 'Street'),
        FlTextField(label: 'City'),
      ],
    );
  }
}

On surface

On a surface, the fields take the secondary variant, filled without a shadow, and the form reads as one card.

Fieldset, on-surface state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/fieldset/fl_fieldset.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

class FieldsetOnSurfaceExample extends StatelessWidget {
  const FieldsetOnSurfaceExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlSurface(
      child: FlFieldset(
        legend: 'Profile settings',
        description: 'Update your profile information.',
        actions: [
          FlButton(label: 'Save changes', onPressed: () {}),
          FlButton(label: 'Cancel', variant: FlButtonVariant.tertiary, onPressed: () {}),
        ],
        children: const [
          FlTextField(label: 'Name', placeholder: 'John Doe', required: true, variant: FlInputVariant.secondary),
          FlTextField(
            label: 'Bio',
            placeholder: 'Tell us about yourself…',
            helper: 'Minimum 10 characters',
            multiline: true,
            variant: FlInputVariant.secondary,
          ),
        ],
      ),
    );
  }
}

Trailing

trailing puts an action on the legend's line, here a reset link, and actionsAlignment sends the buttons to the end.

Fieldset, trailing state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/fieldset/fl_fieldset.dart';
import 'package:your_app/ui/fliin/components/link_button/fl_link_button.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

class FieldsetTrailingExample extends StatelessWidget {
  const FieldsetTrailingExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlFieldset(
      legend: 'Shipping address',
      trailing: FlLinkButton(label: 'Reset', onPressed: () {}),
      actionsAlignment: WrapAlignment.end,
      actions: [
        FlButton(label: 'Cancel', variant: FlButtonVariant.tertiary, onPressed: () {}),
        FlButton(label: 'Save', onPressed: () {}),
      ],
      children: const [
        FlTextField(label: 'Street', placeholder: '12 Market Street'),
        FlTextField(label: 'City', placeholder: 'San Francisco'),
      ],
    );
  }
}

Full width

On a phone, one action at the full width of the form: wrap the button in a SizedBox(width: double.infinity).

Fieldset, full-width state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/fieldset/fl_fieldset.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

class FieldsetFullWidthExample extends StatelessWidget {
  const FieldsetFullWidthExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlFieldset(
      legend: 'Change password',
      description: 'At least 12 characters.',
      actions: [
        SizedBox(
          width: double.infinity,
          child: FlButton(label: 'Update password', onPressed: () {}),
        ),
      ],
      children: const [
        FlTextField(label: 'Current password', obscureText: true),
        FlTextField(label: 'New password', obscureText: true),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = Localizations(
  locale: const Locale('en'),
  delegates: const [DefaultWidgetsLocalizations.delegate],
  child: Directionality(
    textDirection: variant.dir,
    child: Overlay(
      initialEntries: [
        OverlayEntry(
          builder: (context) => ColoredBox(
            color: FlScope.of(context).colors.surface,
            child: Padding(
              padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
              child: SingleChildScrollView(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  spacing: FlScope.of(context).space.xxl,
                  children: [
                    FlFieldset(
                      legend: 'Profile',
                      description: 'Visible on your public page.',
                      actions: [
                        FlButton(label: 'Save', onPressed: () {}),
                        FlButton(
                          label: 'Cancel',
                          variant: FlButtonVariant.tertiary,
                          onPressed: () {},
                        ),
                      ],
                      children: [
                        FlTextField(
                          label: 'Name',
                          required: true,
                          controller: TextEditingController(
                            text: 'Camille Martin',
                          ),
                        ),
                        FlTextField(
                          label: 'Email',
                          placeholder: '[email protected]',
                        ),
                      ],
                    ),
                    FlFieldset(
                      legend: 'Shipping',
                      description: 'Where to send the order.',
                      error: 'Enter a shipping address.',
                      children: [FlTextField(label: 'Address')],
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ],
    ),
  ),
);

API

FlFieldset: class extends StatelessWidget
FlFieldset.new: const new({super.key, required String legend, required List<Widget> children, String? description, String? error, List<Widget> actions, Widget? trailing, WrapAlignment actionsAlignment})
FlFieldset.legend: get String
FlFieldset.description: get String?
FlFieldset.children: get List<Widget>
FlFieldset.error: get String?
FlFieldset.actions: get List<Widget>
FlFieldset.trailing: get Widget?
FlFieldset.actionsAlignment: get WrapAlignment
FlFieldset.build: Widget(BuildContext context)

Reference renders

The 16 images published with version 0.1.1, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify fieldset compares your copy to them.

iOS · Light
Fieldset, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Fieldset, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Fieldset, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Fieldset, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Fieldset, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Fieldset, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Fieldset, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Fieldset, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Fieldset, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Fieldset, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Fieldset, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Fieldset, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Fieldset, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Fieldset, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Fieldset, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Fieldset, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • keep shipping address fields together under a heading
  • split a long form into named sections
  • add a legend and a description above related inputs
  • show one error for a whole group of fields
  • offer save and cancel under a block of inputs
Not for
  • a single field with its label and message: use the field (field)
  • a section of settings rows: use the settings section (settings-section)
  • a whole sign-in form: use the auth form (auth-form)
States
  • Described
  • Error

Source

The files fliin add fieldset copies into your app, before your prefix and imports are applied.

fieldset/fl_fieldset.dart

// Modified from the Fieldset of HeroUI React v3, Copyright 2025 NextUI Inc.,
// licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/core/fliin.dart';

/// Fields that belong together under one title: a
/// [legend] in `textBase` medium, a [description] under it, then the
/// [children] `space.lg` apart, `space.xl` below the title, then the
/// [actions] in a row. When [error] is set, it replaces the description,
/// as in a field: a message for the whole group ("Pick at least one day"),
/// the fields keeping their own.
///
/// [trailing] sits at the end of the legend's line (an "Edit" link, a
/// badge); [actionsAlignment] moves the buttons to the end or the center,
/// and a button in a `SizedBox(width: double.infinity)` takes the full
/// width, as on a phone's form.
///
/// The screen reader reads the legend as a heading, then each field with
/// its own label.
///
/// ```dart
/// FlFieldset(
///   legend: 'Profile',
///   description: 'Shown on your public page.',
///   actions: [FlButton(label: 'Save', onPressed: save)],
///   children: [FlTextField(label: 'Name'), FlTextField(label: 'Email')],
/// )
/// ```
class FlFieldset extends StatelessWidget {
  /// A group of [children] titled [legend].
  const FlFieldset({
    super.key,
    required this.legend,
    required this.children,
    this.description,
    this.error,
    this.actions = const [],
    this.trailing,
    this.actionsAlignment = WrapAlignment.start,
  });

  /// The group's title.
  final String legend;

  /// A line under the title, in `onSurfaceMuted`.
  final String? description;

  /// The fields, in order, stretched to the group's width.
  final List<Widget> children;

  /// A message for the whole group, in `danger`, in place of [description].
  final String? error;

  /// The buttons under the fields ("Save", "Cancel"), `space.sm` apart; the
  /// row wraps when they do not fit.
  final List<Widget> actions;

  /// A widget at the end of the legend's line, centered on it: a link
  /// button, a badge, a switch for the whole group.
  final Widget? trailing;

  /// Where the [actions] sit on their row; at the start by default.
  final WrapAlignment actionsAlignment;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    const role = FlTextRole.textBase;
    final error = this.error;
    final description = this.description;
    final legend = Semantics(
      header: true,
      child: Text(
        this.legend,
        style: theme.typography
            .resolve(role)
            .copyWith(color: theme.colors.onSurface, fontWeight: theme.tokens.typography.weight.medium),
        strutStyle: theme.typography.strut(role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily),
      ),
    );
    final message = error != null
        ? FlFieldError(error, invalid: true)
        : description != null
        ? FlDescription(description, invalid: false)
        : null;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // Tight around their text: at full width, the contrast check took
        // the field below as their background.
        if (trailing case final trailing?)
          Row(
            spacing: theme.space.sm,
            children: [
              Expanded(
                child: Align(alignment: AlignmentDirectional.centerStart, child: legend),
              ),
              trailing,
            ],
          )
        else
          Align(alignment: AlignmentDirectional.centerStart, child: legend),
        if (message != null)
          Padding(
            padding: EdgeInsetsDirectional.only(top: theme.space.xs),
            child: Align(alignment: AlignmentDirectional.centerStart, child: message),
          ),
        SizedBox(height: theme.space.xl),
        Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          spacing: theme.space.lg,
          children: children,
        ),
        if (actions.isNotEmpty)
          Padding(
            // The gap between fields, plus 4 above the actions.
            padding: EdgeInsetsDirectional.only(top: theme.space.xl + theme.space.xs),
            child: Wrap(
              alignment: actionsAlignment,
              spacing: theme.space.sm,
              runSpacing: theme.space.sm,
              children: actions,
            ),
          ),
      ],
    );
  }
}

Arrows to move, Enter to open, Escape to close.