Fliin
On this page

Components / forms

One-time code

Takes a code received by text message or email: one box per digit, in groups, a single field for screen readers, paste and the code the system suggests.

16 of 16 variants pass
One-time code, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add input-otpAlso installs: core, text-field

Examples

Example

A one-time code in two groups of three, with its label and where the code was sent. Typing or pasting fills the cells in order.

One-time code, example state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpExample> createState() => _InputOtpExampleState();
}

class _InputOtpExampleState extends State<InputOtpExample> {
  final _code = TextEditingController(text: '123');

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

  @override
  Widget build(BuildContext context) {
    return FlField(
      label: 'Verify account',
      helper: "We've sent a code to a****@gmail.com",
      child: FlInputOtp(
        key: const ValueKey('otp'),
        label: 'Verification code',
        groups: const [3, 3],
        controller: _code,
      ),
    );
  }
}

Focused

autofocus opens the keyboard on the code; the next empty cell shows the caret.

One-time code, focused state
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpFocusedExample> createState() => _InputOtpFocusedExampleState();
}

class _InputOtpFocusedExampleState extends State<InputOtpFocusedExample> {
  final _code = TextEditingController(text: '12');

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlInputOtp(
        key: const ValueKey('otp'),
        label: 'Verification code',
        groups: const [3, 3],
        controller: _code,
        autofocus: true,
      ),
    );
  }
}

Placeholder

placeholder shows one character per empty cell, a hint of the code's length.

One-time code, placeholder state
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpPlaceholderExample> createState() => _InputOtpPlaceholderExampleState();
}

class _InputOtpPlaceholderExampleState extends State<InputOtpPlaceholderExample> {
  final _code = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlInputOtp(
        key: const ValueKey('otp'),
        label: 'Verification code',
        groups: const [3, 3],
        controller: _code,
        placeholder: '000000',
      ),
    );
  }
}

Error

invalid outlines every cell in danger, for a code the server refused.

One-time code, error state
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpErrorExample> createState() => _InputOtpErrorExampleState();
}

class _InputOtpErrorExampleState extends State<InputOtpErrorExample> {
  final _code = TextEditingController(text: '123456');

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlInputOtp(
        key: const ValueKey('otp'),
        label: 'Verification code',
        groups: const [3, 3],
        controller: _code,
        invalid: true,
      ),
    );
  }
}

Disabled

disabled locks the cells, while the code is being checked for instance.

One-time code, disabled state
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpDisabledExample> createState() => _InputOtpDisabledExampleState();
}

class _InputOtpDisabledExampleState extends State<InputOtpDisabledExample> {
  final _code = TextEditingController(text: '123');

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlInputOtp(
        key: const ValueKey('otp'),
        label: 'Verification code',
        groups: const [3, 3],
        controller: _code,
        disabled: true,
      ),
    );
  }
}

Four digits

A four-digit PIN: a single group, groups: [4], no dash.

One-time code, four-digits state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlField(
      label: 'Enter PIN',
      child: FlInputOtp(label: 'PIN', groups: [4]),
    );
  }
}

Pattern

pattern rejects what does not match: FlInputOtp.onlyChars keeps letters only, and the text keyboard opens instead of the number pad.

One-time code, pattern state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlField(
      label: 'Enter code (letters only)',
      helper: 'Only alphabetic characters are allowed',
      child: FlInputOtp(
        label: 'Code',
        groups: const [3, 3],
        pattern: FlInputOtp.onlyChars,
        keyboardType: TextInputType.text,
      ),
    );
  }
}

Variants

Slots white and lifted (primary), or filled with the control grey (FlInputVariant.secondary) in a white sheet.

One-time code, variants state
import 'package:your_app/ui/fliin/components/input_otp/fl_input_otp.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 InputOtpVariantsExample extends StatelessWidget {
  const InputOtpVariantsExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.md,
      children: const [
        FlInputOtp(label: 'Primary code', groups: [3, 3]),
        FlInputOtp(
          label: 'Secondary code',
          groups: [3, 3],
          variant: FlInputVariant.secondary,
        ),
      ],
    );
  }
}

On complete

onCompleted fires once every slot is filled: the button becomes available, and success traces the accepted code in green.

One-time code, on-complete 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/input_otp/fl_input_otp.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<InputOtpOnCompleteExample> createState() =>
      _InputOtpOnCompleteExampleState();
}

class _InputOtpOnCompleteExampleState extends State<InputOtpOnCompleteExample> {
  final _code = TextEditingController(text: '48151');
  bool _complete = false;
  bool _verified = false;

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.lg,
      children: [
        FlField(
          label: 'Verify account',
          child: FlInputOtp(
            label: 'Verification code',
            groups: const [3, 3],
            controller: _code,
            success: _verified,
            onChanged: (_) => setState(() {
              _complete = false;
              _verified = false;
            }),
            onCompleted: (_) => setState(() => _complete = true),
          ),
        ),
        FlButton(
          label: 'Verify code',
          onPressed: _complete ? () => setState(() => _verified = true) : null,
        ),
      ],
    );
  }
}

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: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                spacing: FlScope.of(context).space.lg,
                children: [
                  FlInputOtp(
                    label: 'Verification code',
                    groups: const [3, 3],
                    controller: TextEditingController(text: '12'),
                    autofocus: true,
                  ),
                  FlInputOtp(
                    label: 'PIN code',
                    groups: const [4],
                    placeholder: '0000',
                  ),
                  FlInputOtp(
                    label: 'Verification code',
                    groups: const [3, 3],
                    controller: TextEditingController(text: '123456'),
                    invalid: true,
                  ),
                  FlInputOtp(
                    label: 'Verification code',
                    groups: const [3, 3],
                    controller: TextEditingController(text: '123'),
                    disabled: true,
                  ),
                ],
              ),
            ),
          ),
        ),
      ],
    ),
  ),
);

API

flOtpFromPaste: String(String pasted, int length)
FlInputOtp: class extends StatefulWidget
FlInputOtp.new: const new({super.key, required String label, required List<int> groups, String? hint, TextEditingController? controller, FocusNode? focusNode, String? placeholder, FlInputVariant variant, bool? invalid, bool success, bool? disabled, RegExp? pattern, String Function(String pasted)? pasteTransformer, TextInputType keyboardType, bool autofocus, ValueChanged<String>? onChanged, ValueChanged<String>? onCompleted})
FlInputOtp.onlyDigits: static get RegExp
FlInputOtp.onlyChars: static get RegExp
FlInputOtp.onlyDigitsAndChars: static get RegExp
FlInputOtp.label: get String
FlInputOtp.groups: get List<int>
FlInputOtp.hint: get String?
FlInputOtp.controller: get TextEditingController?
FlInputOtp.focusNode: get FocusNode?
FlInputOtp.placeholder: get String?
FlInputOtp.variant: get FlInputVariant
FlInputOtp.invalid: get bool?
FlInputOtp.success: get bool
FlInputOtp.disabled: get bool?
FlInputOtp.pattern: get RegExp?
FlInputOtp.pasteTransformer: get String Function(String pasted)?
FlInputOtp.keyboardType: get TextInputType
FlInputOtp.autofocus: get bool
FlInputOtp.onChanged: get ValueChanged<String>?
FlInputOtp.onCompleted: get ValueChanged<String>?
FlInputOtp.createState: State<FlInputOtp>()

Reference renders

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

iOS · Light
One-time code, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
One-time code, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
One-time code, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
One-time code, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
One-time code, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
One-time code, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
One-time code, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
One-time code, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
One-time code, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
One-time code, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
One-time code, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
One-time code, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
One-time code, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
One-time code, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
One-time code, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
One-time code, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • submit the verification code received by SMS
  • ask for a six-digit code
  • ask for a four-digit PIN
  • accept the code suggested by the system
  • accept a pasted sign-in code
  • confirm an account with a one-time code
Not for
  • entering a password: use an obscured text field (text-field)
States
  • Focused
  • Placeholder
  • Error
  • Disabled

Source

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

input_otp/fl_input_otp.dart

// Modified from InputOTP 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/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/text_field/fl_text_field.dart';
import 'package:fliin_ui/core/fliin.dart';

// `input-otp.css` and `input-otp.animation.ts`, specific to this drawing: the
// 8 × 2 separator, the 16 to 18 high
// caret, the perspective of the flipping digit and the text scale capped at
// 1.6 (`maxFontSizeMultiplier`). The slot is `size.otpSlotWidth` ×
// `size.otpSlotHeight`.
const double _separatorWidth = 8;
const double _separatorHeight = 2;
const double _caretShort = 16;
const double _caretTall = 18;
const double _perspective = 1 / 500;
const double _maxTextScale = 1.6;

/// Keeps from pasted text (or dictated, or suggested by the system) the code
/// it contains: exactly [length] digits, neither preceded nor followed by
/// another, otherwise nothing. The default transformer.
String flOtpFromPaste(String pasted, int length) =>
    RegExp('(?<!\\d)(\\d{$length})(?!\\d)').firstMatch(pasted)?.group(1) ?? '';

/// A one-time code input: one slot per character, in
/// [groups] separated by a dash, over a single invisible text field. The
/// screen reader reads that single field; the physical keyboard, pasting and
/// the code suggested by the system (SMS on Android, `oneTimeCode` on iOS)
/// write into it. Each slot is an `FlInputFrame`, white and shadowed without
/// an outline; the next one carries the focus ring and a caret that blinks
/// when the field has focus; each digit flips in. Invalid, each slot is
/// outlined in `danger` and the code shakes once; accepted ([success]), an
/// outline in `success` is traced around each slot in turn.
class FlInputOtp extends StatefulWidget {
  /// A code of `groups.fold(0, +)` characters, named [label].
  const FlInputOtp({
    super.key,
    required this.label,
    required this.groups,
    this.hint,
    this.controller,
    this.focusNode,
    this.placeholder,
    this.variant = FlInputVariant.primary,
    this.invalid,
    this.success = false,
    this.disabled,
    this.pattern,
    this.pasteTransformer,
    this.keyboardType = TextInputType.number,
    this.autofocus = false,
    this.onChanged,
    this.onCompleted,
  });

  /// Digits only.
  static final RegExp onlyDigits = RegExp(r'^\d+$');

  /// Latin letters only (`REGEXP_ONLY_CHARS`).
  static final RegExp onlyChars = RegExp(r'^[a-zA-Z]+$');

  /// Digits and Latin letters (`REGEXP_ONLY_DIGITS_AND_CHARS`).
  static final RegExp onlyDigitsAndChars = RegExp(r'^[a-zA-Z0-9]+$');

  /// The field's name, read by the screen reader ("Verification code").
  final String label;

  /// The number of slots in each group, on either side of a dash: `[3, 3]` for
  /// six digits in two groups, `[4]` for a PIN code.
  final List<int> groups;

  /// Read as a hint by the screen reader ("6 digits received by SMS").
  final String? hint;

  /// The entered code. Null, the field creates its own.
  final TextEditingController? controller;

  /// The focus node; null, the field creates one.
  final FocusNode? focusNode;

  /// A sample character per empty slot, half transparent (`000000`).
  final String? placeholder;

  /// The slots' background: `primary` by default.
  final FlInputVariant variant;

  /// True: each slot is outlined in `danger`. Null, the state of the
  /// `FlFieldScope`.
  final bool? invalid;

  /// True: the app accepted the code. An outline in `success` is traced
  /// around each slot, from the first to the last; under reduced motion, it
  /// appears at once. False again, it goes at once.
  final bool success;

  /// True: the slots fade, no focus nor input. Null, the state of the
  /// `FlFieldScope`.
  final bool? disabled;

  /// The pattern the whole code must follow; input that does not follow it is
  /// rejected. Null: any character passes.
  final RegExp? pattern;

  /// What becomes of text of more than one character arriving at once
  /// (pasted, suggested by the system). Null: [flOtpFromPaste].
  final String Function(String pasted)? pasteTransformer;

  /// The virtual keyboard: digits by default.
  final TextInputType keyboardType;

  /// Takes focus on the first frame.
  final bool autofocus;

  /// Receives the code on every change.
  final ValueChanged<String>? onChanged;

  /// Receives the code when all the slots are filled.
  final ValueChanged<String>? onCompleted;

  int get _length => groups.fold(0, (sum, group) => sum + group);

  @override
  State<FlInputOtp> createState() => _FlInputOtpState();
}

class _FlInputOtpState extends State<FlInputOtp> with SingleTickerProviderStateMixin {
  late final AnimationController _accepted = AnimationController(vsync: this, value: widget.success ? 1 : 0);

  TextEditingController? _ownController;
  FocusNode? _ownFocusNode;
  final GlobalKey<EditableTextState> _editable = GlobalKey<EditableTextState>();

  TextEditingController get _controller => widget.controller ?? (_ownController ??= TextEditingController());
  FocusNode get _focusNode => widget.focusNode ?? (_ownFocusNode ??= FocusNode());

  /// `canRequestFocus` of a provided node before the code sets it: given back
  /// to the caller when it leaves.
  bool _providedCanRequestFocus = true;

  void _adoptFocusNode() {
    if (widget.focusNode case final node?) {
      _providedCanRequestFocus = node.canRequestFocus;
    }
    _focusNode.addListener(_rebuild);
  }

  /// Disabled by itself, otherwise by its field.
  bool _disabled = false;

  void _syncDisabled() {
    _disabled = widget.disabled ?? FlFieldScope.maybeOf(context)?.disabled ?? false;
    _focusNode.canRequestFocus = !_disabled && _providedCanRequestFocus;
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _syncDisabled();
  }

  void _releaseFocusNode(FocusNode node, {required bool provided}) {
    node.removeListener(_rebuild);
    if (provided) node.canRequestFocus = _providedCanRequestFocus;
  }

  @override
  void initState() {
    super.initState();
    _adoptFocusNode();
  }

  @override
  void didUpdateWidget(FlInputOtp oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.focusNode != oldWidget.focusNode) {
      _releaseFocusNode(oldWidget.focusNode ?? _ownFocusNode!, provided: oldWidget.focusNode != null);
      _providedCanRequestFocus = true;
      _adoptFocusNode();
    }
    _syncDisabled();
    if (widget.success != oldWidget.success) _accept(widget.success);
    switch ((oldWidget.controller, widget.controller)) {
      case (final previous?, null):
        _ownController = TextEditingController.fromValue(previous.value);
      case (null, _?):
        _ownController?.dispose();
        _ownController = null;
      case _:
    }
  }

  /// Traces the success outline slot after slot: each one over `medium2`,
  /// `short1` after the previous one.
  void _accept(bool success) {
    final motion = FlMotion.of(context);
    if (!success || motion.reduced) {
      _accepted.value = success ? 1 : 0;
      return;
    }
    _accepted
      ..duration = motion.tokens.medium2 + motion.tokens.short1 * (widget._length - 1)
      ..forward(from: 0);
  }

  @override
  void dispose() {
    _accepted.dispose();
    _releaseFocusNode(_focusNode, provided: widget.focusNode != null);
    _ownFocusNode?.dispose();
    _ownController?.dispose();
    super.dispose();
  }

  void _rebuild() => setState(() {});

  void _changed(String code) {
    widget.onChanged?.call(code);
    if (code.length == widget._length) widget.onCompleted?.call(code);
  }

  void _tap() {
    _focusNode.requestFocus();
    _editable.currentState?.requestKeyboard();
  }

  /// Focus only applies on the next frame: the toolbar waits for it to have
  /// opened the field's selection.
  void _longPress() {
    _focusNode.requestFocus();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _editable.currentState?.showToolbar();
    });
    WidgetsBinding.instance.scheduleFrame();
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final disabled = _disabled;
    final invalid = widget.invalid ?? FlFieldScope.maybeOf(context)?.invalid ?? false;
    // The field carries the input, the slots show it: it is invisible.
    final transparent = theme.colors.onSurface.withValues(alpha: 0);
    final hidden = TextStyle(color: transparent, fontSize: theme.typography.style(FlTextRole.textLg).size);
    final editable = EditableText(
      key: _editable,
      controller: _controller,
      focusNode: _focusNode,
      readOnly: disabled,
      autofocus: widget.autofocus,
      showCursor: false,
      maxLines: 1,
      keyboardType: widget.keyboardType,
      autofillHints: const [AutofillHints.oneTimeCode],
      autocorrect: false,
      enableSuggestions: false,
      inputFormatters: [_OtpFormatter(widget._length, widget.pattern, widget.pasteTransformer)],
      onChanged: _changed,
      style: hidden,
      cursorColor: transparent,
      backgroundCursorColor: transparent,
      enableInteractiveSelection: !disabled,
      selectionControls: FlTextSelectionControls(theme),
      contextMenuBuilder: flTextContextMenuBuilder(context),
      rendererIgnoresPointer: true,
    );
    return ValueListenableBuilder<TextEditingValue>(
      valueListenable: _controller,
      builder: (context, value, _) {
        final code = value.text;
        final focused = _focusNode.hasFocus;
        Widget slot(int index) => _Slot(
          index: index,
          count: widget._length,
          accepted: _accepted,
          char: index < code.length ? code[index] : null,
          placeholder: index < (widget.placeholder?.length ?? 0) ? widget.placeholder![index] : null,
          active: focused && index == code.length,
          variant: widget.variant,
          invalid: invalid,
          disabled: disabled,
        );
        final rows = <Widget>[];
        var start = 0;
        for (final group in widget.groups) {
          if (rows.isNotEmpty) rows.add(const _Separator());
          rows.add(
            Row(
              mainAxisSize: MainAxisSize.min,
              spacing: theme.space.sm,
              children: [for (var i = start; i < start + group; i++) slot(i)],
            ),
          );
          start += group;
        }
        return FlHitTarget(
          child: Semantics(
            label: widget.label,
            hint: widget.hint,
            enabled: !disabled,
            validationResult: invalid ? SemanticsValidationResult.invalid : SemanticsValidationResult.none,
            onTap: disabled ? null : _tap,
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              excludeFromSemantics: true,
              onTap: disabled ? null : _tap,
              onLongPress: disabled ? null : _longPress,
              // Wider than its space (narrow screen, a theme's spacing), the code shrinks
              // instead of overflowing.
              child: FittedBox(
                fit: BoxFit.scaleDown,
                alignment: AlignmentDirectional.centerStart,
                // A code reads left to right, in Arabic and Hebrew too: the first slot
                // stays on the left rather than flipping with the row.
                child: Directionality(
                  textDirection: TextDirection.ltr,
                  child: Stack(
                    children: [
                      ExcludeSemantics(
                        child: FlShake(
                          active: invalid,
                          child: Row(mainAxisSize: MainAxisSize.min, spacing: theme.space.sm, children: rows),
                        ),
                      ),
                      Positioned.fill(child: editable),
                    ],
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

/// How the code is kept: text arriving at once goes through the
/// transformer and replaces the code, everything is cut to the length, input
/// outside the pattern is rejected, and the cursor stays at the end. Only the
/// inserted text goes through the transformer: pasted into a started code, it
/// does not mix with the digits already typed.
class _OtpFormatter extends TextInputFormatter {
  _OtpFormatter(this.length, this.pattern, this.paste);

  final int length;
  final RegExp? pattern;
  final String Function(String pasted)? paste;

  @override
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    var text = newValue.text;
    final previous = oldValue.text;
    if (text.length > previous.length + 1) {
      var start = 0;
      while (start < previous.length && previous[start] == text[start]) {
        start++;
      }
      var end = text.length;
      while (end - start > text.length - previous.length &&
          previous[previous.length - (text.length - end) - 1] == text[end - 1]) {
        end--;
      }
      final inserted = text.substring(start, end);
      text = paste?.call(inserted) ?? flOtpFromPaste(inserted, length);
    }
    text = text.substring(0, math.min(text.length, length));
    if (text.isNotEmpty && pattern != null && !pattern!.hasMatch(text)) {
      return oldValue;
    }
    return TextEditingValue(
      text: text,
      selection: TextSelection.collapsed(offset: text.length),
    );
  }
}

/// The dash between two groups: `separator` (Izydesk's `.otp-dash`).
class _Separator extends StatelessWidget {
  const _Separator();

  @override
  Widget build(BuildContext context) {
    final separator = FlScope.of(context).colors.separator;
    return SizedBox(
      width: _separatorWidth,
      height: _separatorHeight,
      child: DecoratedBox(
        decoration: ShapeDecoration(color: separator, shape: const StadiumBorder()),
      ),
    );
  }
}

class _Slot extends StatelessWidget {
  const _Slot({
    required this.index,
    required this.count,
    required this.accepted,
    required this.char,
    required this.placeholder,
    required this.active,
    required this.variant,
    required this.invalid,
    required this.disabled,
  });

  final int index;
  final int count;
  final Animation<double> accepted;
  final String? char;
  final String? placeholder;
  final bool active;
  final FlInputVariant variant;
  final bool invalid;
  final bool disabled;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    // Izydesk's `.otp-slot`: `textLg` at its regular weight.
    final style = theme.typography.resolve(FlTextRole.textLg);
    final placeholder = this.placeholder;
    final content = Stack(
      alignment: Alignment.center,
      children: [
        if (char == null && !active && placeholder != null)
          Text(placeholder, style: style.copyWith(color: colors.fieldPlaceholder)),
        _Value(
          char: char,
          style: style.copyWith(color: colors.onSurface),
        ),
        if (active && char == null) const _Caret(),
      ],
    );
    final slot = FlInputFrame(
      variant: variant,
      outline: invalid ? colors.danger : null,
      outlineWidth: theme.size.border,
      // The next slot carries the field's focus ring.
      focused: active,
      child: SizedBox(
        width: theme.size.otpSlotWidth,
        height: theme.size.otpSlotHeight,
        child: MediaQuery.withClampedTextScaling(maxScaleFactor: _maxTextScale, child: content),
      ),
    );
    final motion = theme.motion;
    final total = motion.medium2 + motion.short1 * (count - 1);
    final traced = AnimatedBuilder(
      animation: accepted,
      builder: (context, child) {
        // This slot's share of the whole trace, `short1` after the previous one.
        final elapsed = accepted.value * total.inMicroseconds - motion.short1.inMicroseconds * index;
        final progress = (elapsed / motion.medium2.inMicroseconds).clamp(0.0, 1.0);
        return CustomPaint(
          foregroundPainter: progress == 0
              ? null
              : _Trace(
                  progress: motion.decelerate.transform(progress),
                  color: colors.success,
                  width: theme.size.focusRing,
                  radius: theme.shape.radiusControl,
                ),
          child: child,
        );
      },
      child: slot,
    );
    return Opacity(opacity: disabled ? theme.size.disabledOpacity : 1, child: traced);
  }
}

/// The success outline of a slot, drawn up to [progress] of its length from
/// the top start corner, inside the box.
class _Trace extends CustomPainter {
  const _Trace({required this.progress, required this.color, required this.width, required this.radius});

  final double progress;
  final Color color;
  final double width;
  final double radius;

  @override
  void paint(Canvas canvas, Size size) {
    final box = (Offset.zero & size).deflate(width / 2);
    final outline = FlContinuousBorder(radius: math.max(0, radius - width / 2)).getOuterPath(box);
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = width
      ..strokeCap = StrokeCap.round;
    if (progress >= 1) {
      canvas.drawPath(outline, paint);
      return;
    }
    for (final metric in outline.computeMetrics()) {
      canvas.drawPath(metric.extractPath(0, metric.length * progress), paint);
    }
  }

  @override
  bool shouldRepaint(_Trace oldDelegate) =>
      progress != oldDelegate.progress ||
      color != oldDelegate.color ||
      width != oldDelegate.width ||
      radius != oldDelegate.radius;
}

/// A slot's digit: it fades in over `medium1` while flipping around the
/// horizontal axis along `otpEnter`, and fades out over `short2` while
/// flipping along `otpExit`; no motion under reduced motion.
class _Value extends StatefulWidget {
  const _Value({required this.char, required this.style});

  final String? char;
  final TextStyle style;

  @override
  State<_Value> createState() => _ValueState();
}

class _ValueState extends State<_Value> with SingleTickerProviderStateMixin {
  late final AnimationController _shown = AnimationController(vsync: this, value: widget.char == null ? 0 : 1);
  late String? _char = widget.char;

  @override
  void didUpdateWidget(_Value oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.char == oldWidget.char) return;
    final motion = FlMotion.of(context);
    if (widget.char case final char?) {
      _char = char;
      if (motion.reduced) {
        _shown.value = 1;
      } else {
        _shown
          ..value = 0
          ..animateTo(1, duration: motion.tokens.medium1, curve: motion.tokens.otpEnter);
      }
    } else if (motion.reduced) {
      _shown.value = 0;
    } else {
      _shown.animateBack(0, duration: motion.tokens.medium1, curve: motion.tokens.otpExit);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final char = _char;
    if (char == null) return const SizedBox.shrink();
    final short2 = FlMotion.of(context).tokens.short2.inMilliseconds;
    final medium1 = FlMotion.of(context).tokens.medium1.inMilliseconds;
    return AnimatedBuilder(
      animation: _shown,
      builder: (context, child) {
        final t = _shown.value;
        if (t == 0) return const SizedBox.shrink();
        // On exit, the `short2` fade ends before the `medium1` flip.
        final exiting = widget.char == null;
        final opacity = exiting ? math.max(0.0, 1 - (1 - t) * medium1 / short2) : t;
        return Opacity(
          opacity: opacity.clamp(0, 1),
          child: Transform(
            alignment: Alignment.center,
            transform: Matrix4.identity()
              ..setEntry(3, 2, _perspective)
              ..rotateX((1 - t) * math.pi / 2),
            child: child,
          ),
        );
      },
      child: Text(char, style: widget.style),
    );
  }
}

/// The active slot's cursor: `onField`, a field's (Izydesk's `.caret`),
/// `size.cursorWidth` wide, whose opacity goes from 1 to 0 and height from
/// 18 to 16, and back, over
/// `long2` each way along `easeInOutQuad`. Still, solid and 18 high under
/// reduced motion.
class _Caret extends StatefulWidget {
  const _Caret();

  @override
  State<_Caret> createState() => _CaretState();
}

class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin {
  late final AnimationController _blink = AnimationController(vsync: this);

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final motion = FlMotion.of(context);
    if (motion.reduced) {
      _blink
        ..stop()
        ..value = 0;
    } else if (!_blink.isAnimating) {
      _blink.repeat(period: motion.tokens.long2 * 2);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final curve = theme.motion.easeInOutQuad;
    return AnimatedBuilder(
      animation: _blink,
      builder: (context, _) {
        final phase = _blink.value * 2;
        final away = curve.transform(phase <= 1 ? phase : 2 - phase);
        return Opacity(
          opacity: 1 - away,
          child: SizedBox(
            width: theme.size.cursorWidth,
            height: _caretTall - (_caretTall - _caretShort) * away,
            child: DecoratedBox(
              decoration: ShapeDecoration(color: theme.colors.onField, shape: const StadiumBorder()),
            ),
          ),
        );
      },
    );
  }
}

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