Customizable Flutter OTP Input (Zero Dependencies)

Ayush Munot

--

Introduction

Flutter makes it easy to build custom UI components, and OTP input fields are no exception. In this article, we’ll create a customizable, zero-dependency OTP input widget from scratch.

Features

  • Zero dependencies for lightweight integration
  • Fully customizable UI (colors, spacing, and text styles)
  • Smooth focus transitions between OTP fields
  • Handles backspace and keyboard events efficiently
  • Supports different OTP lengths

Full Code

Here’s the complete code for the OTP input widget. You can copy and integrate it directly into your Flutter project.

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

class OTPInputController {
String text = '';
}

class OtpInput extends StatefulWidget {
const OtpInput({
super.key,
required this.length,
required this.activeColor,
required this.inactiveColor,
required this.textStyle,
required this.height,
required this.width,
required this.controller,
this.fillColor = Colors.transparent,
this.spacing = 0,
this.mainAxisAlignment = MainAxisAlignment.spaceEvenly,
this.borderWidth = 1,
});

final int length;
final double spacing;
final double height;
final double width;
final double borderWidth;
final Color activeColor;
final Color inactiveColor;
final Color fillColor;
final TextStyle textStyle;
final MainAxisAlignment mainAxisAlignment;
final OTPInputController controller;

@override
State<OtpInput> createState() => _OtpInputState();
}

class _OtpInputState extends State<OtpInput> {
late final List<TextEditingController> _controllers;
late final List<FocusNode> _focusNodes;
late final List<FocusNode> _keyboardListenerFocusNodes;

@override
void initState() {
super.initState();
_controllers = List.generate(widget.length, (_) => TextEditingController());
_focusNodes = List.generate(widget.length, (_) => FocusNode());
_keyboardListenerFocusNodes = List.generate(widget.length, (_) => FocusNode());
}

@override
void dispose() {
for (var controller in _controllers) {
controller.dispose();
}
for (var node in _focusNodes) {
node.dispose();
}
for (var node in _keyboardListenerFocusNodes) {
node.dispose();
}
super.dispose();
}

void _onKeyEvent(KeyEvent event, int index) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
if (_controllers[index].text.isEmpty && index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
_updateValue();
}
}
}

void _onChanged(String value, int index) {
if (value.length == widget.length) {
for (int i = 0; i < widget.length; i++) {
_controllers[i].text = value[i];
}
_focusNodes[index].unfocus();
} else if (value.isNotEmpty) {
_controllers[index].text = value[0];
if (index < widget.length - 1) {
_focusNodes[index + 1].requestFocus();
} else {
_focusNodes[index].unfocus();
}
}
_updateValue();
}

void _updateValue() {
widget.controller.text =
_controllers.map((controller) => controller.text).join();
}

@override
Widget build(BuildContext context) {
return Form(
child: Row(
mainAxisAlignment: widget.mainAxisAlignment,
children: List.generate(widget.length, (index) {
return SizedBox(
height: widget.height,
width: widget.width,
child: KeyboardListener(
focusNode: _keyboardListenerFocusNodes[index],
onKeyEvent: (event) => _onKeyEvent(event, index),
child: TextFormField(
controller: _controllers[index],
focusNode: _focusNodes[index],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: widget.textStyle,
decoration: InputDecoration(
counterText: "",
filled: true,
fillColor: widget.fillColor,
border: OutlineInputBorder(
borderSide: BorderSide(
color: widget.inactiveColor,
width: widget.borderWidth,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: widget.inactiveColor,
width: widget.borderWidth,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: widget.activeColor,
width: widget.borderWidth,
),
),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _onChanged(value, index),
),
),
);
}),
),
);
}
}
final OTPInputController _otpInputController = OTPInputController();
OtpInput(
length: 4,
activeColor: AppPalette.warmGrey500,
inactiveColor: AppPalette.warmGrey300,
textStyle: Theme.of(context).textTheme.titleSmall!.copyWith(
color: AppPalette.warmGrey800,
fontWeight: FontWeight.w800,
),
height: 56,
width: 56,
fillColor: AppPalette.white,
borderWidth: 2,
controller: _otpInputController,
)

Understanding the Code

Importing Dependencies

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

We import Flutter’s core UI and system services packages to handle text input and keyboard interactions.

OTP Input Controller

class OTPInputController {
String text = '';
}

This controller maintains the OTP value as a string.

Defining the OtpInput Widget

class OtpInput extends StatefulWidget {
const OtpInput({
super.key,
required this.length,
required this.activeColor,
required this.inactiveColor,
required this.textStyle,
required this.height,
required this.width,
required this.controller,
this.fillColor = Colors.transparent,
this.spacing = 0,
this.mainAxisAlignment = MainAxisAlignment.spaceEvenly,
this.borderWidth = 1,
});

final int length;
final double height;
final double width;
final double borderWidth;
final Color activeColor;
final Color inactiveColor;
final Color fillColor;
final TextStyle textStyle;
final double spacing;
final MainAxisAlignment mainAxisAlignment;
final OTPInputController controller;

This widget accepts parameters for customization, such as:

  • length: Number of OTP digits.
  • height and width: Input field dimensions.
  • borderWidth: Width of the border.
  • activeColor and inactiveColor: Border colors when focused and inactive.
  • fillColor: Background color of input fields.
  • textStyle: Style of the OTP text.
  • spacing: Space between OTP fields.
  • mainAxisAlignment: Alignment of the OTP fields.
  • controller: Manages the OTP value.

State Management in _OtpInputState

  late final List<TextEditingController> _controllers;
late final List<FocusNode> _focusNodes;
late final List<FocusNode> _keyboardListenerFocusNodes;

The _controllers list manages text input fields, while _focusNodes handles keyboard focus transitions.

Initializing and Disposing Controllers

@override
void initState() {
super.initState();
_controllers = List.generate(widget.length, (_) => TextEditingController());
_focusNodes = List.generate(widget.length, (_) => FocusNode());
_keyboardListenerFocusNodes = List.generate(widget.length, (_) => FocusNode());
}

@override
void dispose() {
for (var controller in _controllers) {
controller.dispose();
}
for (var node in _focusNodes) {
node.dispose();
}
for (var node in _keyboardListenerFocusNodes) {
node.dispose();
}
super.dispose();
}

The _controllers list manages text input for each OTP field, while _focusNodes controls focus transitions, ensuring smooth navigation. Both are initialized and disposed of properly to prevent memory leaks.

Handling Input Changes and Backspaces

void _onKeyEvent(KeyEvent event, int index) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
if (_controllers[index].text.isEmpty && index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
_updateValue();
}
}
}

This function listens for backspace events and moves the focus to the previous field when necessary.

void _onChanged(String value, int index) {
if (value.length == widget.length) {
for (int i = 0; i < widget.length; i++) {
_controllers[i].text = value[i];
}
_focusNodes[index].unfocus();
} else if (value.isNotEmpty) {
_controllers[index].text = value[0];
if (index < widget.length - 1) {
_focusNodes[index + 1].requestFocus();
} else {
_focusNodes[index].unfocus();
}
}
_updateValue();
}

This method updates fields, moves focus, and efficiently distributes pasted OTP values.

void _updateValue() {
widget.controller.text =
_controllers.map((controller) => controller.text).join();
}

This method collects the text from all OTP fields and updates the controller with the complete OTP value. It ensures that any change in the input fields is reflected immediately in the controller.

Building the UI

  @override
Widget build(BuildContext context) {
return Form(
child: Row(
mainAxisAlignment: widget.mainAxisAlignment,
children: List.generate(widget.length, (index) {
return SizedBox(
height: widget.height,
width: widget.width,
child: KeyboardListener(
focusNode: _keyboardListenerFocusNodes[index],
onKeyEvent: (event) => _onKeyEvent(event, index),
child: TextFormField(
controller: _controllers[index],
focusNode: _focusNodes[index],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: widget.textStyle,
decoration: InputDecoration(
counterText: "",
filled: true,
fillColor: widget.fillColor,
border: OutlineInputBorder(
borderSide: BorderSide(
color: widget.inactiveColor,
width: widget.borderWidth,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: widget.inactiveColor,
width: widget.borderWidth,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: widget.activeColor,
width: widget.borderWidth,
),
),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _onChanged(value, index),
),
),
);
}),
),
);
}

This builds a row of text fields with outlined borders, active focus effects, and keyboard event handling.

Conclusion

This Flutter OTP input widget gives you full control over OTP entry customization with a smooth and intuitive user experience. From seamless focus transitions to efficient backspace handling and OTP pasting, it’s designed to enhance usability.

Want to take it a step further? Try adding animations, customizing the UI to match your app’s branding, or integrating it with your authentication system.

I’d love to hear from you! What features would you add? Have questions or suggestions? Drop a comment below and let’s discuss!

--

--

No responses yet

Write a response