Fliin
On this page

Components / forms

Switch group

Gathers independent on/off settings under one label, description and error, such as alerts or sync options, read as one named group.

16 of 16 variants pass
Switch group, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add switch-groupAlso installs: core, field, control-field

Examples

Example

Independent switches under one label, each with its own value and onChanged. The group turns invalid when every alert is off.

Switch group, example state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupExample extends StatefulWidget {
  const SwitchGroupExample({super.key});

  @override
  State<SwitchGroupExample> createState() => _SwitchGroupExampleState();
}

class _SwitchGroupExampleState extends State<SwitchGroupExample> {
  final _on = <String>{'sounds'};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      invalid: _on.isEmpty,
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Enabled

A switch group with one switch on.

Switch group, enabled state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupEnabledExample extends StatefulWidget {
  const SwitchGroupEnabledExample({super.key});

  @override
  State<SwitchGroupEnabledExample> createState() => _SwitchGroupEnabledExampleState();
}

class _SwitchGroupEnabledExampleState extends State<SwitchGroupEnabledExample> {
  final _on = <String>{'sounds'};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Selected

Every switch on.

Switch group, selected state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupSelectedExample extends StatefulWidget {
  const SwitchGroupSelectedExample({super.key});

  @override
  State<SwitchGroupSelectedExample> createState() => _SwitchGroupSelectedExampleState();
}

class _SwitchGroupSelectedExampleState extends State<SwitchGroupSelectedExample> {
  final _on = <String>{'sounds', 'badges', 'previews'};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Unselected

Every switch off.

Switch group, unselected state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupUnselectedExample extends StatefulWidget {
  const SwitchGroupUnselectedExample({super.key});

  @override
  State<SwitchGroupUnselectedExample> createState() => _SwitchGroupUnselectedExampleState();
}

class _SwitchGroupUnselectedExampleState extends State<SwitchGroupUnselectedExample> {
  final _on = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Disabled

disabled locks every switch of the group.

Switch group, disabled state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupDisabledExample extends StatefulWidget {
  const SwitchGroupDisabledExample({super.key});

  @override
  State<SwitchGroupDisabledExample> createState() => _SwitchGroupDisabledExampleState();
}

class _SwitchGroupDisabledExampleState extends State<SwitchGroupDisabledExample> {
  final _on = <String>{'sounds'};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      disabled: true,
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Invalid

invalid shows the error under the switches.

Switch group, invalid state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupInvalidExample extends StatefulWidget {
  const SwitchGroupInvalidExample({super.key});

  @override
  State<SwitchGroupInvalidExample> createState() => _SwitchGroupInvalidExampleState();
}

class _SwitchGroupInvalidExampleState extends State<SwitchGroupInvalidExample> {
  final _on = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Alerts',
      description: 'For every new message',
      error: 'Keep at least one alert',
      invalid: true,
      options: [
        FlSwitchGroupOption(
          value: _on.contains('sounds'),
          label: 'Sounds',
          onChanged: (on) => setState(() => on ? _on.add('sounds') : _on.remove('sounds')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('badges'),
          label: 'Badges',
          onChanged: (on) => setState(() => on ? _on.add('badges') : _on.remove('badges')),
        ),
        FlSwitchGroupOption(
          value: _on.contains('previews'),
          label: 'Previews',
          onChanged: (on) => setState(() => on ? _on.add('previews') : _on.remove('previews')),
        ),
      ],
    );
  }
}

Framed

framed puts each setting on its own card, for settings placed on the page rather than in a list.

Switch group, framed state
import 'package:your_app/ui/fliin/components/switch_group/fl_switch_group.dart';
import 'package:flutter/widgets.dart';

class SwitchGroupFramedExample extends StatefulWidget {
  const SwitchGroupFramedExample({super.key});

  @override
  State<SwitchGroupFramedExample> createState() => _SwitchGroupFramedExampleState();
}

class _SwitchGroupFramedExampleState extends State<SwitchGroupFramedExample> {
  var _public = true;
  var _status = false;

  @override
  Widget build(BuildContext context) {
    return FlSwitchGroup(
      label: 'Privacy',
      framed: true,
      options: [
        FlSwitchGroupOption(
          value: _public,
          label: 'Public profile',
          description: 'Allow others to see your profile',
          onChanged: (value) => setState(() => _public = value),
        ),
        FlSwitchGroupOption(
          value: _status,
          label: 'Online status',
          description: 'Show when you are active',
          onChanged: (value) => setState(() => _status = value),
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

Localizations(locale: Locale'en', delegates: const [DefaultWidgetsLocalizations.delegate], child: Directionality(textDirection: variant.dir, child: ColoredBox(color: FlScope.of(context).colors.surface, child: Padding(padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.xl, children: [FlSwitchGroup(label: 'Alerts', description: 'For each message received', options: [FlSwitchGroupOption(value: true, onChanged: (_) {}, label: 'Sounds'), FlSwitchGroupOption(value: false, onChanged: (_) {}, label: 'Badges', description: 'On the app icon'), FlSwitchGroupOption(value: true, onChanged: null, label: 'Previews')]), FlSwitchGroup(label: 'Syncing', invalid: true, error: 'Turn on at least one option', options: [FlSwitchGroupOption(value: false, onChanged: (_) {}, label: 'Photos'), FlSwitchGroupOption(value: false, onChanged: (_) {}, label: 'Contacts')])])))))

API

FlSwitchGroupOption: class
FlSwitchGroupOption.new: const new({required bool value, required ValueChanged<bool>? onChanged, required String label, String? description})
FlSwitchGroupOption.value: get bool
FlSwitchGroupOption.onChanged: get ValueChanged<bool>?
FlSwitchGroupOption.label: get String
FlSwitchGroupOption.description: get String?
FlSwitchGroup: class extends StatelessWidget
FlSwitchGroup.new: const new({super.key, required String label, required List<FlSwitchGroupOption> options, String? description, String? error, bool invalid, bool required, bool disabled, bool framed})
FlSwitchGroup.label: get String
FlSwitchGroup.framed: get bool
FlSwitchGroup.options: get List<FlSwitchGroupOption>
FlSwitchGroup.description: get String?
FlSwitchGroup.error: get String?
FlSwitchGroup.invalid: get bool
FlSwitchGroup.required: get bool
FlSwitchGroup.disabled: get bool
FlSwitchGroup.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • show several switches under one heading
  • turn each kind of alert on or off
  • keep sync options together
  • show that at least one switch must stay on
  • add a section of switches to a form
Not for
  • a single switch with its label: use a labeled control (control-field)
  • several choices sent with the form: use a checkbox group (checkbox-group)
  • a settings page as a list: use a list row (list-row)
States
  • Enabled
  • Selected
  • Unselected
  • Disabled
  • Invalid

Source

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

switch_group/fl_switch_group.dart

// Modified from the SwitchGroup of HeroUI React v3, Copyright 2020 Next UI
// Inc., licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the ControlField of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0.
import 'dart:ui' show SemanticsValidationResult;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/control_field/fl_control_field.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/core/fliin.dart';

/// A switch of an [FlSwitchGroup]: a setting independent of the others, its
/// state, its label and its help text.
class FlSwitchGroupOption {
  /// The [label] switch, in the [value] state.
  const FlSwitchGroupOption({required this.value, required this.onChanged, required this.label, this.description});

  /// On or off; the parent holds it.
  final bool value;

  /// Receives the requested state. Null: this switch alone does not respond.
  final ValueChanged<bool>? onChanged;

  /// The row's label, also read by the screen reader.
  final String label;

  /// A help text under the label.
  final String? description;
}

/// A group of switches: one `FlControlField`
/// row per setting, `space.xs` apart, under a shared label and
/// description, with a shared error message. Each switch keeps its own
/// value: the group only brings them together.
///
/// The screen reader reads the group as a node named by [label] and
/// described by [description], then each switch; the error is announced
/// when it appears. [disabled] disables them all.
class FlSwitchGroup extends StatelessWidget {
  /// A [label] group of the [options] settings.
  const FlSwitchGroup({
    super.key,
    required this.label,
    required this.options,
    this.description,
    this.error,
    this.invalid = false,
    this.required = false,
    this.disabled = false,
    this.framed = false,
  });

  /// The group's label, in the `label` role, which names it for the screen
  /// reader.
  final String label;

  /// True: each setting sits on its own `surfaceRaised` card, `space.sm`
  /// apart, as settings placed on the page.
  final bool framed;

  /// The settings, top to bottom.
  final List<FlSwitchGroupOption> options;

  /// A help text under the label, in `textSm`.
  final String? description;

  /// The error message under the settings, shown when [invalid] is true.
  final String? error;

  /// True: the combination is rejected; the switches take a `danger` outline, and
  /// [error] appears.
  final bool invalid;

  /// True: the label carries an asterisk and the group announces itself as
  /// required.
  final bool required;

  /// True: no switch responds, and the whole group fades.
  final bool disabled;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final description = this.description;
    final error = this.error;
    return FlFieldScope(
      invalid: invalid,
      disabled: disabled,
      required: required,
      child: Semantics(
        container: true,
        explicitChildNodes: true,
        label: label,
        hint: description,
        isRequired: required ? true : null,
        validationResult: invalid ? SemanticsValidationResult.invalid : SemanticsValidationResult.none,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ExcludeSemantics(
              child: _header(context, label: label, description: description, required: required, disabled: disabled),
            ),
            for (final option in options)
              Padding(
                padding: EdgeInsetsDirectional.only(top: framed ? theme.space.sm : theme.space.xs),
                child: FlControlField(
                  value: option.value,
                  onChanged: disabled ? null : option.onChanged,
                  label: option.label,
                  description: option.description,
                  invalid: invalid,
                  framed: framed,
                ),
              ),
            if (error != null)
              Padding(
                padding: EdgeInsetsDirectional.only(top: invalid ? theme.space.xs : 0),
                child: FlFieldError(error),
              ),
          ],
        ),
      ),
    );
  }
}

/// A group's header: [label] in the `label` role, `onSurface`, with a
/// `danger` asterisk when [required]; [description] in `textSm`
/// `onSurfaceMuted` under it. Neither turns `danger`: the outlines and the
/// error carry the invalid state. Disabled, it fades.
Widget _header(
  BuildContext context, {
  required String label,
  String? description,
  required bool required,
  required bool disabled,
}) {
  final theme = FlScope.of(context);
  final colors = theme.colors;
  final scale = theme.typography;
  final family = DefaultTextStyle.of(context).style.fontFamily;
  final header = Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    spacing: theme.space.xs,
    children: [
      Text.rich(
        TextSpan(
          text: label,
          children: [
            if (required)
              TextSpan(
                text: ' *',
                semanticsLabel: '',
                style: TextStyle(color: disabled ? colors.onSurfaceMuted : colors.danger),
              ),
          ],
        ),
        style: scale.resolve(FlTextRole.label).copyWith(color: colors.onSurface),
        strutStyle: scale.strut(FlTextRole.label, inheritedFamily: family),
      ),
      if (description != null)
        Text(
          description,
          style: scale.resolve(FlTextRole.textSm).copyWith(color: colors.onSurfaceMuted),
          strutStyle: scale.strut(FlTextRole.textSm, inheritedFamily: family),
        ),
    ],
  );
  return disabled ? Opacity(opacity: theme.size.disabledOpacity, child: header) : header;
}

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