Fliin
On this page

Components / forms

Field label, description and error

Says what a field asks for, explains it and reports its error: label with a required asterisk, description, announced error message.

16 of 16 variants pass
Field label, description and error, enabled state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add fieldAlso installs: core

Examples

Enabled

FlField puts a label above any control and a help line under it, at the field's own gap; required adds the asterisk and tells the control.

Field label, description and error, enabled state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlField(
      label: 'Email address',
      helper: "We'll use this to contact you",
      required: true,
      child: FlInput(label: 'Email address', placeholder: 'Enter your email'),
    );
  }
}

Invalid

With an error, the message replaces the help line and the control inside turns invalid: the input is outlined in danger.

Field label, description and error, invalid state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<FieldInvalidExample> createState() => _FieldInvalidExampleState();
}

class _FieldInvalidExampleState extends State<FieldInvalidExample> {
  final _email = TextEditingController(text: 'name@');

  @override
  void dispose() {
    _email.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FlField(
      label: 'Email address',
      helper: "We'll use this to contact you",
      error: 'Please enter a valid email address',
      required: true,
      child: FlInput(label: 'Email address', controller: _email),
    );
  }
}

Disabled

disabled: true fades the label, the control and the help line together, and locks the input inside.

Field label, description and error, disabled state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlField(
      label: 'Email address',
      helper: "We'll use this to contact you",
      disabled: true,
      required: true,
      child: FlInput(label: 'Email address', placeholder: 'Enter your email'),
    );
  }
}

Parts

The parts on their own, for a layout of your own: FlFieldScope sets required and invalid once, and FlLabel, FlInput, FlDescription and FlFieldError read them. flFieldGap is the field's gap.

Field label, description and error, parts state
import 'package:your_app/ui/fliin/components/field/fl_field.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:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlFieldScope(
      required: true,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: flFieldGap(FlScope.of(context)),
        children: const [
          FlLabel('Email address'),
          FlInput(label: 'Email address', placeholder: 'Enter your email'),
          FlDescription("We'll use this to contact you", hideOnInvalid: true),
          FlFieldError('Please enter a valid email address'),
        ],
      ),
    );
  }
}

Label trailing

labelTrailing puts a widget across from the label: here the link to reset a forgotten password, on the sign-in form.

Field label, description and error, label-trailing state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/field/fl_field.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 FieldLabelTrailingExample extends StatelessWidget {
  const FieldLabelTrailingExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlField(
      label: 'Password',
      labelTrailing: FlLinkButton(
        label: 'Forgot password?',
        size: FlButtonSize.sm,
        tone: FlLinkButtonTone.muted,
        onPressed: () {},
      ),
      child: const FlInput(label: 'Password', obscureText: true),
    );
  }
}

Counter

A text area that counts: the help line follows the text, and the error takes its place while the bio is too short.

Field label, description and error, counter state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<FieldCounterExample> createState() => _FieldCounterExampleState();
}

class _FieldCounterExampleState extends State<FieldCounterExample> {
  static const _min = 20;
  final _bio = TextEditingController(text: 'Product designer in Lyon');

  @override
  void dispose() {
    _bio.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final length = _bio.text.length;
    return FlField(
      label: 'Bio',
      helper: 'Minimum $_min characters ($length/$_min).',
      error: length > 0 && length < _min
          ? 'Bio must contain at least $_min characters.'
          : null,
      child: FlInput(
        label: 'Bio',
        controller: _bio,
        placeholder: 'Tell us about yourself…',
        multiline: true,
        rows: 3,
        onChanged: (_) => setState(() {}),
      ),
    );
  }
}

Usage

The code the reference images render:

final example = 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.sm,
      children: [
        FlFieldScope(
          invalid: false,
          disabled: false,
          required: true,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            spacing: FlScope.of(context).space.xs,
            children: [
              FlLabel('Email address'),
              FlDescription('For receipts only.', hideOnInvalid: true),
              FlFieldError('Invalid address.'),
            ],
          ),
        ),
        FlFieldScope(
          invalid: true,
          disabled: false,
          required: true,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            spacing: FlScope.of(context).space.xs,
            children: [
              FlLabel('Email address'),
              FlDescription('For receipts only.', hideOnInvalid: true),
              FlFieldError('Invalid address.'),
            ],
          ),
        ),
        FlFieldScope(
          invalid: false,
          disabled: true,
          required: true,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            spacing: FlScope.of(context).space.xs,
            children: [
              FlLabel('Email address'),
              FlDescription('For receipts only.', hideOnInvalid: true),
              FlFieldError('Invalid address.'),
            ],
          ),
        ),
      ],
    ),
  ),
);

API

flFieldGap: double(FlTheme theme)
FlField: class extends StatelessWidget
FlField.new: const new({super.key, required String label, Widget? child, String? helper, String? error, bool required, bool disabled, bool? invalid, Widget? labelTrailing})
FlField.label: get String
FlField.child: get Widget?
FlField.helper: get String?
FlField.error: get String?
FlField.required: get bool
FlField.disabled: get bool
FlField.invalid: get bool?
FlField.labelTrailing: get Widget?
FlField.build: Widget(BuildContext context)
FlLabel: class extends StatelessWidget
FlLabel.new: const new(String text, {super.key, bool? invalid, bool? disabled, bool? required})
FlLabel.text: get String
FlLabel.invalid: get bool?
FlLabel.disabled: get bool?
FlLabel.required: get bool?
FlLabel.build: Widget(BuildContext context)
FlDescription: class extends StatelessWidget
FlDescription.new: const new(String text, {super.key, bool? invalid, bool? disabled, bool hideOnInvalid})
FlDescription.text: get String
FlDescription.invalid: get bool?
FlDescription.disabled: get bool?
FlDescription.hideOnInvalid: get bool
FlDescription.build: Widget(BuildContext context)
FlFieldError: class extends StatelessWidget
FlFieldError.new: const new(String text, {super.key, bool? invalid})
FlFieldError.text: get String
FlFieldError.invalid: get bool?
FlFieldError.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 field compares your copy to them.

iOS · Light
Field label, description and error, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Field label, description and error, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Field label, description and error, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Field label, description and error, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Field label, description and error, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Field label, description and error, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Field label, description and error, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Field label, description and error, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Field label, description and error, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Field label, description and error, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Field label, description and error, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Field label, description and error, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Field label, description and error, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Field label, description and error, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Field label, description and error, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Field label, description and error, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • add a label to a form field
  • show an asterisk on a required field
  • explain what a field expects
  • show an input error
  • show the message of an invalid field
Not for
  • text input: use a text field (text-field)
States
  • Enabled
  • Invalid
  • Disabled

Source

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

field/fl_field.dart

// Modified from Label, Description and FieldError of HeroUI Native 1.0.10,
// Copyright 2025 NextUI Inc., licensed under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
import 'dart:math' as math;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The gap between a field's label, its control and its message: Izydesk's
/// `.field` gap (`space.xs`), plus whatever the control's touch target
/// overflows its frame by (a compact control under the platform's target),
/// so that the target never reaches the label or the message.
double flFieldGap(FlTheme theme) => theme.space.xs + math.max(0, (theme.hitTarget - theme.controlHeight) / 2);

/// What a field part reads: its own setting, otherwise that of the enclosing
/// [FlFieldScope], otherwise false.
typedef _FieldState = ({bool invalid, bool disabled, bool required, bool inset});

_FieldState _state(BuildContext context, {bool? invalid, bool? disabled, bool? required}) {
  final scope = FlFieldScope.maybeOf(context);
  return (
    invalid: invalid ?? scope?.invalid ?? false,
    disabled: disabled ?? scope?.disabled ?? false,
    required: required ?? scope?.required ?? false,
    inset: scope?.inset ?? false,
  );
}

/// A field text in the role [role], the color [color], inset by
/// `size.fieldInset` in a field that asks for it.
Widget _text(
  BuildContext context,
  FlTextRole role,
  _FieldState state, {
  required List<InlineSpan> spans,
  required Color color,
}) {
  final theme = FlScope.of(context);
  final scale = theme.typography;
  final metrics = scale.style(role);
  final system = scale.familyOf(role) == null;
  final style = scale.resolve(role).copyWith(color: color);
  final inherited = DefaultTextStyle.of(context).style.fontFamily;
  final text = Padding(
    padding: EdgeInsetsDirectional.symmetric(horizontal: state.inset ? theme.size.fieldInset : 0),
    child: Text.rich(
      TextSpan(children: spans),
      // The role's line height, enforced for the system font (see FlText).
      // Like React Native's lineHeight, it fixes the line: without it, the line
      // spacing of text distributed proportionally, or the larger asterisk, would
      // make it overflow the strut.
      style: system ? style.copyWith(height: metrics.lineHeight / metrics.size) : style,
      strutStyle: system
          ? StrutStyle.fromTextStyle(
              style,
              fontFamily: inherited,
              height: metrics.lineHeight / metrics.size,
              leadingDistribution: TextLeadingDistribution.even,
              forceStrutHeight: true,
            )
          : scale.strut(role, inheritedFamily: inherited),
    ),
  );
  // A disabled field announces itself as such, and its half-opaque text falls
  // outside the contrast rule, like a disabled control (WCAG 1.4.3).
  return state.disabled ? Semantics(enabled: false, child: text) : text;
}

/// A whole field (Izydesk's `.field`): an [FlLabel] above [child], then an
/// [FlDescription] or, when [error] is set, an [FlFieldError] in its place,
/// `flFieldGap` apart. The label carries a `danger` asterisk when [required];
/// the whole field is half opaque when [disabled]; [labelTrailing] sits at
/// the end of the label's line. [child] (an `FlInput`, an
/// `FlInputGroup`, a one-time code…) reads the invalid, disabled and required
/// states from the [FlFieldScope] this field sets around it, and carries
/// its own name for the screen reader: the visible label is not read again.
///
/// ```dart
/// FlField(
///   label: 'Site',
///   error: invalid ? 'Invalid address' : null,
///   child: FlInputGroup(prefix: Text('https://'), decorativePrefix: true, child: FlInput(label: 'Site')),
/// )
/// ```
class FlField extends StatelessWidget {
  /// A field labeled [label], around [child].
  const FlField({
    super.key,
    required this.label,
    this.child,
    this.helper,
    this.error,
    this.required = false,
    this.disabled = false,
    this.invalid,
    this.labelTrailing,
  });

  /// The visible label, above [child].
  final String label;

  /// The control; null for a label and its message alone.
  final Widget? child;

  /// The help under the control, replaced by [error] when there is one.
  final String? helper;

  /// The error message: when set, the field is invalid.
  final String? error;

  /// Required: an asterisk after the label, announced as required.
  final bool required;

  /// Disabled: the label, the control and the message at
  /// `size.disabledOpacity`.
  final bool disabled;

  /// Invalid without a message (the message shown elsewhere); when null,
  /// invalid when [error] is set.
  final bool? invalid;

  /// A widget at the end of the label's line, across from it: a "Forgot
  /// password?" link, a character count, an "Optional" tag. It keeps its
  /// own semantics. When null, the label stands alone.
  final Widget? labelTrailing;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final error = this.error;
    final trailing = labelTrailing;
    final helper = this.helper;
    final child = this.child;
    return FlFieldScope(
      invalid: invalid ?? error != null,
      disabled: disabled,
      required: required,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: flFieldGap(theme),
        children: [
          // Tight around its text: at full width, the contrast check took
          // the control below as the label's background.
          if (trailing == null)
            Align(
              alignment: AlignmentDirectional.centerStart,
              // The control carries its own name: the label is read once.
              child: child == null ? FlLabel(label) : ExcludeSemantics(child: FlLabel(label)),
            )
          else
            Row(
              spacing: theme.space.sm,
              children: [
                Expanded(
                  child: Align(
                    alignment: AlignmentDirectional.centerStart,
                    child: child == null ? FlLabel(label) : ExcludeSemantics(child: FlLabel(label)),
                  ),
                ),
                trailing,
              ],
            ),
          ?child,
          if (error != null)
            Align(alignment: AlignmentDirectional.centerStart, child: FlFieldError(error, invalid: true))
          else if (helper != null)
            Align(alignment: AlignmentDirectional.centerStart, child: FlDescription(helper, invalid: false)),
        ],
      ),
    );
  }
}

/// A field's label: `textSm` in medium weight,
/// `onSurface`, even when the field is invalid (Izydesk's `.label`: the
/// outline and the error say it); a `danger` asterisk when it is required,
/// and half opaque when it is disabled.
/// Each null setting comes from the enclosing [FlFieldScope].
class FlLabel extends StatelessWidget {
  /// The label [text].
  const FlLabel(this.text, {super.key, this.invalid, this.disabled, this.required});

  /// The label text.
  final String text;

  /// Invalid. Kept for the API: the label no longer changes color, the field's
  /// outline and error carry the state.
  final bool? invalid;

  /// Disabled: `size.disabledOpacity`, the asterisk included.
  final bool? disabled;

  /// Required: an asterisk follows the text. The screen reader reads the
  /// label without the asterisk, and announces it as required.
  final bool? required;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final state = _state(context, invalid: invalid, disabled: disabled, required: required);
    final label = _text(
      context,
      FlTextRole.textSm,
      state,
      color: colors.onSurface,
      spans: [
        TextSpan(
          text: text,
          style: TextStyle(fontWeight: theme.tokens.typography.weight.medium),
        ),
        if (state.required)
          TextSpan(
            text: ' *',
            semanticsLabel: '',
            style: TextStyle(fontWeight: theme.tokens.typography.weight.normal, color: colors.danger),
          ),
      ],
    );
    final announced = state.required ? Semantics(isRequired: true, child: label) : label;
    return state.disabled ? Opacity(opacity: theme.size.disabledOpacity, child: announced) : announced;
  }
}

/// A field's description: `textSm`,
/// `onSurfaceMuted`, `danger` when the field is invalid, half opaque when it
/// is disabled. [hideOnInvalid] fades it out when the error replaces it.
class FlDescription extends StatelessWidget {
  /// The description [text].
  const FlDescription(this.text, {super.key, this.invalid, this.disabled, this.hideOnInvalid = false});

  /// The text.
  final String text;

  /// Invalid: `danger`, or removed with [hideOnInvalid].
  final bool? invalid;

  /// Disabled: `size.disabledOpacity`.
  final bool? disabled;

  /// True: the description fades out when the field is invalid.
  final bool hideOnInvalid;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final state = _state(context, invalid: invalid, disabled: disabled);
    final description = _text(
      context,
      FlTextRole.textSm,
      state,
      color: state.invalid ? theme.colors.danger : theme.colors.onSurfaceMuted,
      spans: [TextSpan(text: text)],
    );
    final shown = state.disabled ? Opacity(opacity: theme.size.disabledOpacity, child: description) : description;
    if (!hideOnInvalid) return shown;
    return _Presence(visible: !state.invalid, child: shown);
  }
}

/// A field's error message: `textSm` `danger`,
/// present only when the field is invalid, fading in over `short3` and out
/// over `short2`. The screen reader announces it when it appears.
class FlFieldError extends StatelessWidget {
  /// The message [text].
  const FlFieldError(this.text, {super.key, this.invalid});

  /// The message.
  final String text;

  /// Invalid: the message is shown; null, the state of the [FlFieldScope].
  final bool? invalid;

  @override
  Widget build(BuildContext context) {
    final state = _state(context, invalid: invalid);
    return _Presence(
      visible: state.invalid,
      child: Semantics(
        liveRegion: true,
        child: _text(
          context,
          FlTextRole.textSm,
          state,
          color: FlScope.of(context).colors.danger,
          spans: [TextSpan(text: text)],
        ),
      ),
    );
  }
}

/// Fades [child] in over `short3` (`easeOut`) and out over `short2`, its
/// space included; instant under reduced motion.
class _Presence extends StatefulWidget {
  const _Presence({required this.visible, required this.child});

  final bool visible;
  final Widget child;

  @override
  State<_Presence> createState() => _PresenceState();
}

class _PresenceState extends State<_Presence> with SingleTickerProviderStateMixin {
  late final AnimationController _opacity = AnimationController(vsync: this, value: widget.visible ? 1 : 0);

  @override
  void didUpdateWidget(_Presence oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.visible == oldWidget.visible) return;
    final motion = FlMotion.of(context);
    final target = widget.visible ? 1.0 : 0.0;
    if (motion.reduced) {
      _opacity.value = target;
    } else {
      _opacity.animateTo(
        target,
        duration: widget.visible ? motion.tokens.short3 : motion.tokens.short2,
        curve: motion.tokens.easeOut,
      );
    }
  }

  @override
  void dispose() {
    _opacity.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => AnimatedBuilder(
    animation: _opacity,
    builder: (context, child) =>
        _opacity.value == 0 ? const SizedBox.shrink() : FadeTransition(opacity: _opacity, child: child),
    child: widget.child,
  );
}

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