I'm trying to build a pixel-perfect design in Flutter that includes a TextField. The design requires the label to behave like this:
I need: I need to design a pixel-perfect UI according to the design below:

But I'm currently having this:

The Problem: I have a prefix widget (like a country code selector for a phone number) that is always visible. Because of this, the label behaves as if the TextField is always focused — it's always displayed in its floating position, even when the field is not focused or filled.
Current Code What I've tried: Here is the code that I've tried to get the target design.
TextField(
keyboardType: TextInputType.phone,
scrollPadding: EdgeInsets.zero,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
label: Text(
'Phone number',
style: TextStyle(
color: AppColors.secondaryTextColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
errorText: showError ? formState['phoneError'] : null,
errorMaxLines: 2,
errorStyle: const TextStyle(fontSize: 12),
// Content Padding
contentPadding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
// Prefix for country code
prefix: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CountryCodePicker(
onChanged: (country) {
formNotifier.updateCountryCode(
country.dialCode ?? '+91', country.name ?? 'India');
},
textStyle: const TextStyle(
fontSize: 16,
color: AppColors.primaryTextColor,
fontWeight: FontWeight.w600,
),
initialSelection: formState['countryCode'],
favorite: const ['IN', 'BD', 'US'],
showFlag: false,
showDropDownButton: true,
alignLeft: false,
padding: EdgeInsets.zero,
flagWidth: 24,
builder: (country) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
country?.dialCode ?? '+91',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Gap(8),
],
),
),
const VerticalDivider(
thickness: 1,
width: 1,
color: AppColors.defaultBorderColor,
),
const SizedBox(width: 8),
],
),
),
// Suffix Icons for validation
suffixIcon: showError
? const Icon(Icons.close, color: Colors.red)
: isValid
? const Icon(Icons.check, color: Colors.green)
: null,
// Borders
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
const BorderSide(color: AppColors.defaultBorderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.darkBackgroundColor, width: 2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
const BorderSide(color: AppColors.defaultBorderColor),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.red, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.red, width: 2),
),
),
onChanged: (value) => formNotifier.updateField('phone', value),
)
Looking at the UI you want to achieve, I don't think that is possible using the native Flutter TextField.
Try building a custom text field using Stack and manage the label manually with AnimatedPositioned.
Here is a simple reusable component you can expand on:
import 'package:flutter/material.dart';
class PhoneInputField extends StatefulWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final String label;
final String countryCode;
final bool showError;
final bool isValid;
final String? errorMessage;
final ValueChanged<String>? onChanged;
const PhoneInputField({
super.key,
required this.controller,
this.focusNode,
this.label = "Phone number",
this.countryCode = "+1",
this.showError = false,
this.isValid = false,
this.errorMessage,
this.onChanged,
});
@override
State<PhoneInputField> createState() => _PhoneInputFieldState();
}
class _PhoneInputFieldState extends State<PhoneInputField> {
late final FocusNode _internalFocusNode;
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode;
bool get _hasText => widget.controller.text.trim().isNotEmpty;
bool get _isFocused => _focusNode.hasFocus;
@override
void initState() {
super.initState();
_internalFocusNode = FocusNode();
widget.controller.addListener(_refresh);
_focusNode.addListener(_refresh);
}
void _refresh() => setState(() {});
@override
void dispose() {
if (widget.focusNode == null) _internalFocusNode.dispose();
widget.controller.removeListener(_refresh);
_focusNode.removeListener(_refresh);
super.dispose();
}
@override
Widget build(BuildContext context) {
final borderColor = widget.showError
? Colors.red
: _isFocused
? Colors.black
: Colors.grey[400]!;
final showIcon = !_hasText
? null
: widget.showError
? Icons.close
: widget.isValid
? Icons.check
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
height: 56,
decoration: BoxDecoration(
border: Border.all(color: borderColor, width: 1.5),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// replace with your own code dropdwon or text
Text(
widget.countryCode,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
//////
const VerticalDivider(width: 20, thickness: 1),
Expanded(
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(top: 18),
),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
onChanged: widget.onChanged,
),
),
if (showIcon != null)
Icon(
showIcon,
color: showIcon == Icons.check
? Colors.green
: Colors.red,
),
],
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 66,
top: _isFocused || _hasText ? 8 : 18,
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: _isFocused || _hasText ? 12 : 16,
color: widget.showError
? Colors.red
: _isFocused
? Colors.black
: Colors.grey[600],
fontWeight: FontWeight.w500,
),
child: Text(widget.label),
),
),
],
),
const SizedBox(height: 6),
if (widget.showError && widget.errorMessage != null)
Text(
widget.errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
],
);
}
}
And here is how you can use it in your form (checkout, profile, etc.):
PhoneInputField(
controller: _controller,
focusNode: _focusNode,
countryCode: "+1",
isValid: _isValid,
showError: _showError,
errorMessage: "enter valid phone number",
onChanged: _validate,
),