Components / forms
Checkbox group
Ticks several options under one label, description and error, such as interests or alert channels, read by screen readers as one named group.
Import
In a Flutter app where fliin init has run:
Examples
Example
Several independent choices under one label: value is the set of checked options. Here a required group stays invalid until one is checked.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupExample extends StatefulWidget {
const CheckboxGroupExample({super.key});
@override
State<CheckboxGroupExample> createState() => _CheckboxGroupExampleState();
}
class _CheckboxGroupExampleState extends State<CheckboxGroupExample> {
var _chosen = <String>{};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
required: true,
invalid: _chosen.isEmpty,
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Enabled
A checkbox group with one option checked; an option can carry its own description.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupEnabledExample extends StatefulWidget {
const CheckboxGroupEnabledExample({super.key});
@override
State<CheckboxGroupEnabledExample> createState() => _CheckboxGroupEnabledExampleState();
}
class _CheckboxGroupEnabledExampleState extends State<CheckboxGroupEnabledExample> {
var _chosen = <String>{'coding'};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Selected
Any number of options can be checked at once.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupSelectedExample extends StatefulWidget {
const CheckboxGroupSelectedExample({super.key});
@override
State<CheckboxGroupSelectedExample> createState() => _CheckboxGroupSelectedExampleState();
}
class _CheckboxGroupSelectedExampleState extends State<CheckboxGroupSelectedExample> {
var _chosen = <String>{'coding', 'design'};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Unselected
Nothing checked yet.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupUnselectedExample extends StatefulWidget {
const CheckboxGroupUnselectedExample({super.key});
@override
State<CheckboxGroupUnselectedExample> createState() => _CheckboxGroupUnselectedExampleState();
}
class _CheckboxGroupUnselectedExampleState extends State<CheckboxGroupUnselectedExample> {
var _chosen = <String>{};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Disabled
disabled locks every option and keeps the checked ones visible.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupDisabledExample extends StatefulWidget {
const CheckboxGroupDisabledExample({super.key});
@override
State<CheckboxGroupDisabledExample> createState() => _CheckboxGroupDisabledExampleState();
}
class _CheckboxGroupDisabledExampleState extends State<CheckboxGroupDisabledExample> {
var _chosen = <String>{'coding'};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
disabled: true,
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Invalid
invalid shows the error under the options.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupInvalidExample extends StatefulWidget {
const CheckboxGroupInvalidExample({super.key});
@override
State<CheckboxGroupInvalidExample> createState() => _CheckboxGroupInvalidExampleState();
}
class _CheckboxGroupInvalidExampleState extends State<CheckboxGroupInvalidExample> {
var _chosen = <String>{};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
error: 'Choose at least one',
required: true,
invalid: true,
value: _chosen,
onChanged: (value) => setState(() => _chosen = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Select all
selectAllLabel adds a box that checks them all, mixed while only some are checked; the others are indented under it.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupSelectAllExample extends StatefulWidget {
const CheckboxGroupSelectAllExample({super.key});
@override
State<CheckboxGroupSelectAllExample> createState() => _CheckboxGroupSelectAllExampleState();
}
class _CheckboxGroupSelectAllExampleState extends State<CheckboxGroupSelectAllExample> {
var _interests = {'coding'};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Interests',
selectAllLabel: 'Select all',
value: _interests,
onChanged: (value) => setState(() => _interests = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding'),
FlCheckboxGroupOption(value: 'design', label: 'Design'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
],
);
}
}Framed
framed puts each box on its own card: add-ons to pick, each with its description.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupFramedExample extends StatefulWidget {
const CheckboxGroupFramedExample({super.key});
@override
State<CheckboxGroupFramedExample> createState() => _CheckboxGroupFramedExampleState();
}
class _CheckboxGroupFramedExampleState extends State<CheckboxGroupFramedExample> {
var _channels = {'email'};
@override
Widget build(BuildContext context) {
return FlCheckboxGroup<String>(
label: 'Notification preferences',
description: 'Choose how you want to receive updates',
framed: true,
value: _channels,
onChanged: (value) => setState(() => _channels = value),
options: const [
FlCheckboxGroupOption(value: 'email', label: 'Email notifications', description: 'Receive updates by email'),
FlCheckboxGroupOption(value: 'sms', label: 'SMS alerts', description: 'Get instant text messages'),
FlCheckboxGroupOption(value: 'push', label: 'Push notifications', description: 'Alerts on this device'),
],
);
}
}On surface
On a surface, a sheet or a dialog, FlControlFieldVariant.secondary gives the boxes the background that stands out from it.
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:your_app/ui/fliin/components/control_field/fl_control_field.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:flutter/widgets.dart';
class CheckboxGroupOnSurfaceExample extends StatefulWidget {
const CheckboxGroupOnSurfaceExample({super.key});
@override
State<CheckboxGroupOnSurfaceExample> createState() => _CheckboxGroupOnSurfaceExampleState();
}
class _CheckboxGroupOnSurfaceExampleState extends State<CheckboxGroupOnSurfaceExample> {
var _interests = {'design'};
@override
Widget build(BuildContext context) {
return FlSurface(
child: FlCheckboxGroup<String>(
label: 'Select your interests',
description: 'Choose all that apply',
variant: FlControlFieldVariant.secondary,
value: _interests,
onChanged: (value) => setState(() => _interests = value),
options: const [
FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
FlCheckboxGroupOption(value: 'design', label: 'Design', description: 'Enjoy creating interfaces'),
FlCheckboxGroupOption(value: 'writing', label: 'Writing', description: 'Passionate about content'),
],
),
);
}
}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: [FlCheckboxGroup<String>(label: 'Interests', description: 'Choose everything you like', value: const {'cuisine'}, onChanged: (_) {}, options: [FlCheckboxGroupOption(value: 'cuisine', label: 'Cooking'), FlCheckboxGroupOption(value: 'voyage', label: 'Travel', description: 'Stays and deals'), FlCheckboxGroupOption(value: 'sport', label: 'Sports', disabled: true)]), FlCheckboxGroup<String>(label: 'Alerts', required: true, invalid: true, error: 'Choose at least one channel', value: const {}, onChanged: (_) {}, options: [FlCheckboxGroupOption(value: 'mail', label: 'Email'), FlCheckboxGroupOption(value: 'sms', label: 'SMS')])])))))API
FlCheckboxGroupOption: class<T>
FlCheckboxGroupOption.new: const new({required T value, required String label, String? description, bool disabled})
FlCheckboxGroupOption.value: get T
FlCheckboxGroupOption.label: get String
FlCheckboxGroupOption.description: get String?
FlCheckboxGroupOption.disabled: get bool
FlCheckboxGroup: class<T> extends StatefulWidget
FlCheckboxGroup.new: const new({super.key, required String label, required Set<T> value, required ValueChanged<Set<T>> onChanged, required List<FlCheckboxGroupOption<T>> options, String? description, String? error, bool invalid, bool required, bool disabled, FlControlFieldVariant variant, bool framed, String? selectAllLabel})
FlCheckboxGroup.label: get String
FlCheckboxGroup.variant: get FlControlFieldVariant
FlCheckboxGroup.framed: get bool
FlCheckboxGroup.selectAllLabel: get String?
FlCheckboxGroup.value: get Set<T>
FlCheckboxGroup.onChanged: get ValueChanged<Set<T>>
FlCheckboxGroup.options: get List<FlCheckboxGroupOption<T>>
FlCheckboxGroup.description: get String?
FlCheckboxGroup.error: get String?
FlCheckboxGroup.invalid: get bool
FlCheckboxGroup.required: get bool
FlCheckboxGroup.disabled: get bool
FlCheckboxGroup.createState: State<FlCheckboxGroup<T>>()Reference renders
The 16 images published with version 0.2.2, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify checkbox-group compares your copy to them.
















Use it to
- select several options under one label
- pick your interests from a list
- show that at least one option must be ticked
- show one shared error for several checkboxes
- ask which notification channels to use
- Not for
- a single option among several: use radio buttons (radio)
- a single box with its label: use a labeled control (control-field)
- grouped switches: use a switch group (switch-group)
- States
Source
The files fliin add checkbox-group copies into your app, before your prefix and imports are applied.
checkbox_group/fl_checkbox_group.dart
// Modified from the CheckboxGroup 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/checkbox/fl_checkbox.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 box of a [FlCheckboxGroup]: the value it adds to the selection, its
/// label and its help text.
class FlCheckboxGroupOption<T> {
/// The box [value], named [label].
const FlCheckboxGroupOption({required this.value, required this.label, this.description, this.disabled = false});
/// The value the box adds to the selection when checked.
final T value;
/// The row's label, also read by the screen reader.
final String label;
/// A help text under the label.
final String? description;
/// True: this box alone does not respond.
final bool disabled;
}
/// A group of checkboxes: a shared label
/// and description, one `FlControlField` row per box, then the group's
/// error message. The selection is the set [value] of checked values, held
/// by the parent.
///
/// The screen reader reads the group as a node named by [label] and
/// described by [description], then each box; the error is announced when
/// it appears. [invalid] outlines each box in `danger`, [disabled] disables
/// them all. A minimum or maximum number of boxes is validated by the
/// caller, through [invalid] and [error]: [required] only
/// sets the asterisk and the required state.
class FlCheckboxGroup<T> extends StatefulWidget {
/// A group [label] whose [options] boxes are checked according to [value].
const FlCheckboxGroup({
super.key,
required this.label,
required this.value,
required this.onChanged,
required this.options,
this.description,
this.error,
this.invalid = false,
this.required = false,
this.disabled = false,
this.variant = FlControlFieldVariant.primary,
this.framed = false,
this.selectAllLabel,
});
/// The group's label, in the `label` role, which names it for the screen
/// reader.
final String label;
/// The background of the boxes: `primary` on the page by default,
/// `secondary` on a surface, a sheet or a dialog.
final FlControlFieldVariant variant;
/// True: each box sits on its own `surfaceRaised` card, `space.sm` apart,
/// a choice of plans or add-ons rather than a plain list.
final bool framed;
/// Non-null: a box of that name ("Select all") above the others, which
/// are indented under it. It is checked when every box is, mixed when
/// some are; tapping it checks every enabled box, or clears them all
/// once they are all checked.
final String? selectAllLabel;
/// The values of the checked boxes.
final Set<T> value;
/// Receives the new set when a box changes; the parent passes it back in
/// [value]. Two boxes tapped before the parent passes back the first add
/// up: the second set starts from the first, not from [value].
final ValueChanged<Set<T>> onChanged;
/// The boxes, top to bottom.
final List<FlCheckboxGroupOption<T>> options;
/// A help text under the label, in `textSm`.
final String? description;
/// The error message under the boxes, shown when [invalid] is true.
final String? error;
/// True: the selection is rejected; the boxes take a `danger` outline, and
/// [error] appears.
final bool invalid;
/// True: the label carries an asterisk and the group announces itself required.
final bool required;
/// True: no box responds, and the whole group fades.
final bool disabled;
@override
State<FlCheckboxGroup<T>> createState() => _FlCheckboxGroupState<T>();
}
class _FlCheckboxGroupState<T> extends State<FlCheckboxGroup<T>> {
/// The last set sent, or the parent's value when it passes one back.
late Set<T> _latest = widget.value;
@override
void didUpdateWidget(FlCheckboxGroup<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_latest = widget.value;
}
void _toggle(T option, bool on) {
_latest = on ? {..._latest, option} : ({..._latest}..remove(option));
widget.onChanged(_latest);
}
/// Checks every enabled box, or clears them once all are checked; a
/// disabled box keeps its state.
void _toggleAll() {
final enabled = {
for (final option in widget.options)
if (!option.disabled) option.value,
};
final all = widget.options.every((option) => _latest.contains(option.value));
_latest = all ? ({..._latest}..removeAll(enabled)) : {..._latest, ...enabled};
widget.onChanged(_latest);
}
/// The box that checks them all, mixed while only some are checked: the
/// same row as the others, its checkbox carrying the semantics.
Widget _selectAll(FlTheme theme, String label) {
final options = widget.options;
final checked = options.where((option) => widget.value.contains(option.value)).length;
final bool? state = checked == 0 ? false : (checked == options.length ? true : null);
final enabled = !widget.disabled && options.any((option) => !option.disabled);
final family = DefaultTextStyle.of(context).style.fontFamily;
final text = Text(
label,
style: theme.typography
.resolve(FlTextRole.textBase)
.copyWith(color: theme.colors.onSurface, fontWeight: theme.tokens.typography.weight.normal),
strutStyle: theme.typography.strut(FlTextRole.textBase, inheritedFamily: family),
);
return ConstrainedBox(
constraints: BoxConstraints(minHeight: theme.size.controlCompact),
child: Row(
spacing: theme.space.md,
children: [
FlCheckbox(
value: state,
tristate: true,
label: label,
invalid: widget.invalid,
variant: widget.variant == FlControlFieldVariant.secondary
? FlCheckboxVariant.secondary
: FlCheckboxVariant.primary,
onChanged: enabled ? (_) => _toggleAll() : null,
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: enabled ? _toggleAll : null,
child: ExcludeSemantics(
child: enabled ? text : Opacity(opacity: theme.size.disabledOpacity, child: text),
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
final FlCheckboxGroup(:label, :description, :error, :invalid, :required, :disabled, :options, :value) = widget;
final selectAll = widget.selectAllLabel;
// Indented under "Select all": each box under its label.
final indent = selectAll == null ? 0.0 : theme.size.toggle + theme.space.md;
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),
),
if (selectAll != null)
Padding(
padding: EdgeInsetsDirectional.only(top: theme.space.sm),
child: _selectAll(theme, selectAll),
),
for (final option in options)
Padding(
padding: EdgeInsetsDirectional.only(top: theme.space.sm, start: indent),
child: FlControlField(
value: value.contains(option.value),
onChanged: disabled || option.disabled ? null : (on) => _toggle(option.value, on),
label: option.label,
description: option.description,
indicator: FlControlFieldIndicator.checkbox,
variant: widget.variant,
invalid: invalid,
framed: widget.framed,
),
),
if (error != null)
Padding(
padding: EdgeInsetsDirectional.only(top: invalid ? theme.space.sm : 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 / 2,
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;
}