Inputs & Fields

OTP Input Field

A polished 6-digit OTP verification field built in pure Flutter — no packages. Each digit lives in its own box with auto-focus and auto-advance as the user types, automatic back-navigation on delete, a shake animation on an invalid code, and a built-in 60-second resend timer. Drops into any phone or email verification flow.

authinputanimation
9:41

Verify your email

Enter the 6-digit code we sent to your inbox.

4
9
2

Didn't get it? Resend code

WHAT'S INCLUDED

  • Six individual digit boxes built from TextField with maxLength 1 and centered text
  • Auto-advance to the next box on entry and auto-return to the previous box on delete
  • digitsOnly input formatter so only numbers are accepted
  • Shake animation via AnimationController for an invalid or rejected code
  • Built-in 60-second resend countdown that re-enables the Resend code button
  • onCompleted callback that fires with the full code once every box is filled

USE CASES

  • Email or SMS one-time-password verification after sign up or login
  • Two-factor authentication (2FA) confirmation screens
  • Phone number verification during onboarding
  • Any "enter the code we sent you" step in a secure action

PRO TIP

Call shake() and clear the controllers together when your backend rejects a code — the shake gives instant visual feedback while the cleared boxes invite a retry. For SMS autofill on Android, wrap the row in an AutofillGroup and set autofillHints: const [AutofillHints.oneTimeCode] so the keyboard offers the incoming code automatically.

Complete Dart Code

Copy this entire snippet into your Flutter project. No external packages required.

dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        backgroundColor: Colors.white,
        body: Center(
          child: Padding(
            padding: EdgeInsets.all(24),
            child: OtpInputField(),
          ),
        ),
      ),
    );
  }
}

class OtpInputField extends StatefulWidget {
  final int length;
  final ValueChanged<String>? onCompleted;

  const OtpInputField({
    super.key,
    this.length = 6,
    this.onCompleted,
  });

  @override
  State<OtpInputField> createState() => _OtpInputFieldState();
}

class _OtpInputFieldState extends State<OtpInputField>
    with SingleTickerProviderStateMixin {
  late final List<TextEditingController> _controllers;
  late final List<FocusNode> _focusNodes;
  late final AnimationController _shakeController;
  late final Animation<double> _shakeAnimation;

  Timer? _resendTimer;
  int _resendSeconds = 60;

  @override
  void initState() {
    super.initState();
    _controllers =
        List.generate(widget.length, (_) => TextEditingController());
    _focusNodes = List.generate(widget.length, (_) => FocusNode());
    _shakeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 400),
    );
    _shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
    );
    _startResendTimer();
  }

  void _startResendTimer() {
    _resendTimer?.cancel();
    setState(() => _resendSeconds = 60);
    _resendTimer = Timer.periodic(const Duration(seconds: 1), (t) {
      if (_resendSeconds <= 0) {
        t.cancel();
        return;
      }
      setState(() => _resendSeconds--);
    });
  }

  String getOtp() => _controllers.map((c) => c.text).join();

  void shake() {
    _shakeController.forward(from: 0);
  }

  void _onChanged(int index, String value) {
    if (value.isNotEmpty && index < widget.length - 1) {
      _focusNodes[index + 1].requestFocus();
    } else if (value.isEmpty && index > 0) {
      _focusNodes[index - 1].requestFocus();
    }
    if (getOtp().length == widget.length) {
      widget.onCompleted?.call(getOtp());
    }
  }

  @override
  void dispose() {
    for (final c in _controllers) c.dispose();
    for (final f in _focusNodes) f.dispose();
    _shakeController.dispose();
    _resendTimer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedBuilder(
          animation: _shakeAnimation,
          builder: (context, child) {
            final dx = _shakeAnimation.value == 0
                ? 0.0
                : 8 *
                    (1 - _shakeAnimation.value) *
                    (_shakeAnimation.value * 4 % 1 < 0.5 ? 1 : -1);
            return Transform.translate(
              offset: Offset(dx, 0),
              child: child,
            );
          },
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: List.generate(widget.length, (index) {
              return SizedBox(
                width: 48,
                height: 56,
                child: TextField(
                  controller: _controllers[index],
                  focusNode: _focusNodes[index],
                  keyboardType: TextInputType.number,
                  textAlign: TextAlign.center,
                  maxLength: 1,
                  style: const TextStyle(
                    fontSize: 22,
                    fontWeight: FontWeight.w600,
                  ),
                  inputFormatters: [
                    FilteringTextInputFormatter.digitsOnly,
                  ],
                  decoration: const InputDecoration(
                    counterText: '',
                    border: OutlineInputBorder(),
                  ),
                  onChanged: (v) => _onChanged(index, v),
                ),
              );
            }),
          ),
        ),
        const SizedBox(height: 16),
        TextButton(
          onPressed: _resendSeconds == 0 ? _startResendTimer : null,
          child: Text(
            _resendSeconds == 0
                ? 'Resend code'
                : 'Resend in ${_resendSeconds}s',
          ),
        ),
      ],
    );
  }
}

How to use this widget

  1. 1Copy the Dart code above
  2. 2Create a new file in your Flutter project — for example otp_input_field.dart
  3. 3Drop OtpInputField into your verification screen and pass an onCompleted callback that sends the entered code to your backend. Call shake() if the code is rejected.

Related components

Auth & Onboarding

Login Screen

Clean email + password login with form validation and loading state

View component →
Auth & Onboarding

Register Screen

Full name, email, password registration with confirm password validation

View component →
Auth & Onboarding

Forgot Password Screen

Email input with send reset link flow and success state

View component →