Skip to main content

Build an App Language Selector Users Can Recover From

2026-10-14

Build an App Language Selector Users Can Recover From

An app language selector can trap the user it is supposed to help. Someone picks the wrong language, every setting label changes, and the control needed to undo the choice is now unreadable. Other failures are quieter: Chinese script variants collapse into one label, a translated label is saved as the locale identity, Android Settings and the in-app picker disagree, or a custom font turns one option into empty boxes.

To avoid that trap, keep the selector recognizable, give every locale a stable identity, display names the target reader can understand, and verify the effective locale after a change. The work runs from locale registry design through platform synchronization and recovery testing.

Why a normal translated menu fails

Most settings menus translate their labels and options into the current interface language. That works for themes or notification preferences because the user can already read the screen. A language selector cannot assume the current interface language is readable.

Suppose an English reader accidentally chooses Japanese. If the settings row now says only 言語 and the English option says only 英語, the user must recognize two Japanese words before they can recover. Moving the selector deeper into a translated settings tree makes the path harder to rediscover. A search field translated into the wrong language adds another obstacle.

W3C's guidance on selectors for localized content recommends writing target options in the target language, such as français instead of French. It also discusses optional clarification in the current page language, missing glyphs, mixed-script ordering, and the limits of a plain select control. Although that guide addresses web navigation, the recovery problem applies directly to an app picker.

Readable labels can still point to the wrong locale. A display string such as Português does not say whether the app will load pt, pt-BR, or pt-PT. 中文 does not identify a script or region. If code stores that display text, a later copy change can break lookup, and two distinct locale records can receive the same visible name.

Android adds another source of locale state by exposing an app's language in system Settings. Its per-app language preferences documentation explains that custom in-app pickers should use the platform or AndroidX APIs so both surfaces stay synchronized. An app that writes only its old custom preference can show one selected language while Android stores another.

Define one locale registry

Start with a reviewed registry of the locales the product actually ships. Do not generate the picker from every locale installed on the device, every translation directory found in a dependency, or every language known to an internationalization library. Those sets include options the app may not support completely.

Each registry entry needs stable machine fields and reviewed display fields:

  • A canonical BCP 47 tag used as the item identity, such as en, pt-BR, sr-Cyrl, or zh-Hant-TW.
  • A target-language name, often called an autonym, such as Deutsch or 日本語.
  • An optional clarification translated into the current interface language.
  • Search aliases for common names, scripts, and regions.
  • Text direction and a font-coverage test fixture.
  • Product state such as enabled, preview, or retired.

Unicode CLDR defines structured locale display-name data for languages, scripts, territories, and composed locale names. Use CLDR-backed APIs or data for generated labels, then review the exact launch list. Product names and community preferences can differ from a mechanically composed label, and a generic language name may hide a script choice that matters to the user.

Keep the canonical tag separate from every label. Labels may change with the current interface language, typography, or editorial review. Item identity must remain stable across those changes.

Use a model like this:

type SupportedLocale = {
  tag: string;
  autonym: string;
  clarificationKey?: string;
  aliases: string[];
  direction: "ltr" | "rtl";
  status: "enabled" | "preview" | "retired";
};

const supportedLocales: SupportedLocale[] = [
  {
    tag: "en",
    autonym: "English",
    aliases: ["english"],
    direction: "ltr",
    status: "enabled",
  },
  {
    tag: "sr-Cyrl",
    autonym: "српски (ћирилица)",
    clarificationKey: "language.serbianCyrillic",
    aliases: ["serbian", "cyrillic", "srpski"],
    direction: "ltr",
    status: "enabled",
  },
  {
    tag: "zh-Hant-TW",
    autonym: "正體中文(台灣)",
    clarificationKey: "language.traditionalChineseTaiwan",
    aliases: ["chinese", "taiwan", "zhuyin"],
    direction: "ltr",
    status: "enabled",
  },
];

The registry should live close to locale-resolution code and release validation, rather than being copied into each client screen. A build check can compare enabled entries with packaged resources and the product's approved locale list.

Make the selector findable after a bad choice

Give language selection a stable location. A settings row near the top level works better than a control buried under a translated category whose location changes between releases. During onboarding, provide the same route instead of implementing a second picker with different data.

The entry point needs more than a translated word. Pair the localized label with a recognizable language or globe symbol and an accessibility name. W3C notes that a text label such as "Select language" is unreadable to visitors who do not know that language. The symbol should support recognition, while accessible text and the list contents carry the meaning for screen-reader users.

Do not use country flags as the only language identity. A language can span countries, and one country can use several languages. Flags also fail to express script differences such as Serbian Latin versus Cyrillic. If region matters to formatting or content, show it as text within the locale name.

Display each option with the autonym first. Add a current-language clarification when it helps distinguish unfamiliar scripts or regions, for example Deutsch (German) on an English screen. When the current interface language is wrong, the autonym remains readable to the intended speaker. When fonts fail, the clarification can still expose the choice.

Long lists need search, but search cannot depend only on the current interface language. Normalize the query for matching and search across the autonym, current-language name, stable tag, script, region, and reviewed aliases. Keep the original text for display. Accent-insensitive matching can help discovery, but it should not rewrite the language name.

Ordering a mixed-script list has no universal answer. Alphabetical order changes with the collation locale and may split scripts in ways users do not expect. Choose a deterministic policy and test it. Common policies include product-priority groups followed by locale-aware order within each group, or script groups with autonym order inside each group. Do not silently reorder the list every time the app language changes if that makes a familiar option jump to another location during recovery.

Apply a selection through one authority

Treat the selector as a view over locale state. It reads the effective app locale, submits a canonical tag, and re-reads the result after the platform finishes its configuration change. It never saves the visible label.

Use this sequence:

  1. Confirm that the selected tag is an enabled registry entry.
  2. Write the tag through the platform's supported app-locale API.
  3. Let the platform perform the required configuration update.
  4. Re-read the application locale and resolve the effective locale.
  5. Re-render the selector from the same registry.
  6. Record a bounded diagnostic if the requested and effective tags differ.

On Android, LocaleManager or the AndroidX compatibility path keeps the in-app value aligned with system Settings. The Android guide also covers locale configuration, persistence, split APKs, and migration from older custom storage. A Delta Chat contributor reported different behavior between in-app and app-info language selectors, which shows why both surfaces belong in the same verification matrix.

Do not continuously replay a legacy preference on startup. That creates two authorities and can overwrite a choice made in system Settings. If the app is migrating old state, consume the legacy value once under a versioned migration guard, then let the platform-owned locale state win.

Some supported app languages are unavailable as device languages. An OpenTracks translator requested an explicit in-app language list for exactly that case. Build the picker from the app registry, and use the device locale only as an input to default resolution. Device support should not remove a reviewed app locale from the list.

Handle script, region, and fallback deliberately

Store the locale the user chose, not a shortened approximation. If the registry offers zh-Hans and zh-Hant, collapsing both to zh discards the choice. If it offers pt-BR and pt-PT, selecting by the first language subtag can highlight the wrong row after relaunch.

Fallback remains separate from selection. The user may choose fr-CA while the app falls back to fr for a missing resource. The selector should still show the explicit fr-CA choice if that is a supported registry entry. Showing fr instead hides a coverage defect and changes the user's saved intent.

Retired locales need a policy. Keep a retired tag readable long enough to migrate existing users, map it only through an approved rule, and explain any forced change. Guessing the nearest region can alter terminology, units, legal content, or storefront behavior.

Font coverage belongs in the release gate. Render every autonym with the production font stack, including combining marks and right-to-left scripts. W3C's selector guidance calls out missing glyphs in multilingual lists. A picker that shows tofu cannot serve as a recovery route, even if the rest of the locale loads correctly after selection.

Failure handling

A failed locale change should leave the previous known-good state visible. Do not persist a new custom tag before the platform accepts it, then hope startup repairs the mismatch. Submit the requested locale, re-read the effective value, and show a clear error in both the previous language and the target autonym when they differ.

If a registry entry has no packaged resources in the release artifact, block the build. Runtime fallback should cover individual missing messages, not advertise a locale whose core screens were never shipped. If the selected locale's font fixture fails, block that locale or restore a tested font chain before release.

If the app cannot parse a stored legacy tag, preserve the raw value in private diagnostics, use the system default, and show the selector in its stable location. Do not infer a locale from a translated label or a country code.

Accessibility failures also need recovery tests. Verify focus order, control name, selected-state announcement, and the pronunciation of autonyms. A screen reader should expose each option even when the visible list mixes left-to-right and right-to-left text.

Verify the recovery path

Test the selector as a state transition, not a screenshot. For every enabled locale, assert the requested tag, effective tag, visible selected row, resource locale, text direction, and persisted platform state.

Cover these cases:

  • Open the picker from every supported locale, including one the tester cannot read.
  • Select the wrong locale, terminate the process, relaunch, and recover without external instructions.
  • Switch between language-only, script-specific, and region-specific entries.
  • Search by autonym, current-language name, script, region, and canonical tag.
  • Render the complete list with production fonts and large accessibility text.
  • Navigate and select with a screen reader in both text directions.
  • Change the language in Android system Settings and verify the in-app row.
  • Change it in the app and verify Android system Settings.
  • Select an app-supported language that the device does not offer globally.
  • Upgrade from legacy custom storage and restore from backup.
  • Remove or retire one locale and verify the approved migration behavior.
  • Force an unavailable-resource build and confirm that release validation fails.

Add one manual recovery timer to locale QA. Give a tester an app in an unfamiliar language and ask them to return it to a known language without a walkthrough. Record where they hesitate. That test catches buried navigation, ambiguous icons, unreadable names, and unstable ordering that unit tests cannot see.

Next action

Create the supported-locale registry before redesigning the screen. Give each launch locale a canonical tag, reviewed autonym, optional clarification, aliases, direction, and release status. Then build one automated test that selects an unfamiliar locale, restarts the app, opens the same stable route, and restores the original language. Do not ship the selector until that recovery test and the platform synchronization checks pass.

References

  1. W3C: Using a selector to link to localized content supports target-language option names, optional clarifications, UTF-8, font checks, and multilingual ordering concerns.
  2. Unicode CLDR: Locale display names supports structured display names for languages, scripts, regions, and composed locales.
  3. Android Developers: Per-app language preferences supports custom-picker synchronization, platform locale APIs, persistence, split APK behavior, and migration requirements.
  4. GitHub: Delta Chat language-selector consistency request provides a practitioner report about alignment between in-app and Android app-info selectors.
  5. GitHub: OpenTracks in-app language-list request provides a practitioner case for an app language unavailable as an Android system language.