Login Screen
Clean email + password login with form validation and loading state
View component →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.
Enter the 6-digit code we sent to your inbox.
Didn't get it? Resend code
WHAT'S INCLUDED
USE CASES
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.
Copy this entire snippet into your Flutter project. No external packages required.
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',
),
),
],
);
}
}
Clean email + password login with form validation and loading state
View component →Full name, email, password registration with confirm password validation
View component →Email input with send reset link flow and success state
View component →