Two lines of autofill are worth more than a week of form polish
Compose's autofill is a semantics property on a text field. Declaring content types lets password managers and the platform fill forms correctly, and omitting it is why users abandon sign-up.

Day 54 of 100, closing the text pillar. This is the shortest-effort, highest-impact change in the series, and most apps ship without it.
The symptom
A login screen that password managers ignore:
Column {
TextField(state = emailState, label = { Text("Email") })
SecureTextField(state = passwordState, label = { Text("Password") })
Button(onClick = ::signIn) { Text("Sign in") }
}
It works, and the user's password manager shows nothing. They switch apps, copy the password, switch back, paste, and discover the field cleared when the app went to the background.
For an app people sign into once a month, that friction is measurable in abandoned sessions — and it's invisible to a developer whose credentials are in the emulator's autofill service already.
Why the obvious fix fails
The obvious fix is to make the labels clearer:
TextField(state = emailState, label = { Text("Email address") })
Autofill services don't read your labels. They read semantics — a structured description of what each field is for, which the accessibility and autofill systems share. A field with no declared content type is an anonymous text box, and a password manager correctly declines to guess.
The other non-fix is an autofillHints string copied from a View-system answer. Compose
has its own API, and the older Modifier.semantics { contentDescription = "password" }
approach conflates autofill with screen-reader labelling — two different jobs.

The actual mechanism
Declare the content type in semantics, and the platform does the rest:
TextField(
state = emailState,
label = { Text("Email") },
modifier = Modifier.semantics { contentType = ContentType.EmailAddress },
)
SecureTextField(
state = passwordState,
label = { Text("Password") },
modifier = Modifier.semantics { contentType = ContentType.Password },
)
Two modifiers. The password manager now offers to fill both, and — the part people forget — offers to save the credential after a successful sign-up.
ContentType covers the fields forms actually contain: Username, Password,
NewUsername, NewPassword, EmailAddress, PhoneNumber, PersonFullName,
PersonFirstName, PersonLastName, PostalAddress, PostalCode, AddressCountry,
CreditCardNumber, CreditCardExpirationDate, CreditCardSecurityCode,
SmsOtpCode, and more.
Combine them with + where a field serves two roles:
Modifier.semantics { contentType = ContentType.Username + ContentType.EmailAddress }
That's the right declaration for a login field that accepts either — and it lets the manager offer the correct stored value rather than guessing from the label.
New password versus password
The distinction that makes sign-up work:
// Sign IN — offer saved credentials
Modifier.semantics { contentType = ContentType.Password }
// Sign UP — offer to GENERATE a strong one, and save it after
Modifier.semantics { contentType = ContentType.NewPassword }
Using Password on a registration screen means the manager offers the user's existing
password rather than generating a new one, which is the small nudge that leads to
password reuse. NewPassword is what triggers the generate-and-save flow.
Pair it with the confirm field declared the same way, so the manager fills both.
Autofill also interacts with the validation from Day 38. A generated password can be
thirty characters of mixed symbols, so a maxLength of 20 or an InputTransformation
that strips punctuation will silently mangle it — and the user finds out at their next
sign-in, not at sign-up. Password fields should constrain as little as possible.
One-time codes
The SMS code screen is the one users hate most, and it's one declaration:
TextField(
state = otpState,
modifier = Modifier.semantics { contentType = ContentType.SmsOtpCode },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)
The code appears as a keyboard suggestion the moment the SMS arrives. No SMS-read
permission, no message parsing — the platform does it, and asking for
READ_SMS to achieve the same thing is both worse for the user and a Play Store review
risk.
Saving needs a committed action
Filling works from declarations alone. Saving needs the platform to know the form was submitted:
val autofillManager = LocalAutofillManager.current
Button(onClick = {
signIn(emailState.text.toString(), passwordState.text.toString())
autofillManager?.commit() // "this form was submitted"
}) { Text("Sign in") }
Without commit(), a new credential is often not offered for saving, so the user has to
type it again next time and concludes autofill doesn't work in your app.
cancel() is the counterpart for a form the user abandoned, which suppresses a save
prompt for a half-filled form.
Passkeys are where this is going
Worth knowing while you're in this code: the Credential Manager API covers passkeys, passwords and federated sign-in through one request, and it's the direction Android is pushing sign-in.
Autofill and Credential Manager solve overlapping problems — autofill fills any form, Credential Manager handles authentication specifically and can offer a passkey that replaces the password entirely. An app doing new sign-in work should look at Credential Manager first, and still declare content types for the rest of its forms: addresses, payment fields and the profile screen all benefit from autofill and are outside Credential Manager's scope.
The interaction with state
Two details specific to Compose:
Autofill writes to the state object, so TextFieldState picks it up like any other
edit — no callback to wire, and Day 52's undo history records it as one transaction.
Backgrounding must not clear the field. The copy-paste dance at the top of this post
fails when the field is remember rather than rememberSaveable, because switching to
the password manager can recreate the activity. rememberTextFieldState is saveable by
default, which is one more reason Day 38's migration pays off here.
How to prove it
The declarations are assertable in a test, which is worth having so a refactor can't silently drop them:
@Test fun loginFieldsDeclareContentTypes() = runComposeUiTest {
setContent { LoginScreen() }
onNodeWithTag("email").assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentType))
onNodeWithTag("password").assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentType))
}
On device, the real test needs a populated autofill service: set one up in system settings,
save a credential for your app once, then reinstall and try to sign in. If nothing is
offered, a declaration is missing or commit() never fired.
The end-to-end check worth doing once per release: sign up with a generated password, sign out, sign back in. Any step where you have to type is a step some fraction of users won't complete. Doing it on a device with a third-party manager rather than the Google default is worth it once, since the two behave differently on partially-declared forms.
What this generalizes to
The pillar's closing point: declaring what something is gets you behaviour you didn't
implement. A content type gets autofill, a Role gets correct screen-reader
announcements, a LinkAnnotation gets link semantics, a heading gets navigation.
Five days of text keep returning to that. AnnotatedString works because the styling is
part of the content rather than assembled by layout; TextFieldState works because it
declares the whole state rather than a projection; autofill works because the field says
what it holds. In each case the payoff came from describing the thing accurately and
letting the platform act on the description — which is cheaper than the alternative and
arrives with behaviours you would not have thought to build. In each case the payoff came from describing the thing accurately and
letting the platform act on the description.
Tomorrow, Day 55 opens the images and graphics pillar.
Day 54 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Autofill in Compose.