Resources and Localization in Compose Multiplatform

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Android has res/ and the generated R class; iOS has asset catalogs, .strings files and Bundle.main; desktop and web have neither. Compose Multiplatform (CMP) replaces all of them with one resource system that lives in common code: a composeResources directory in a source set, a Gradle plugin that turns it into a typed, generated Res class, and a small runtime library that reads the files on every target. The same stringResource(Res.string.app_name) call works on Android, iOS, desktop and web, and the same qualifier folders (values-es, drawable-dark, drawable-xxhdpi) select the right variant on each.

The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 and Xcode 26.4. Stability levels used below: the resource library, the Res class, qualifiers and the compose.resources Gradle block are Stable; the web preloading helpers and a few helper APIs behind @ExperimentalResourceApi are Experimental; Kotlin/Wasm and CMP for web are Beta. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate): the shared screens live in the Gradle module sharedUI, so that is where composeResources lives. The Android model this page generalises is on Resources, Configuration, Localization and Themes; Compose basics are not re-taught — see Compose Fundamentals — and the shared UI stack is introduced on Compose Multiplatform Fundamentals.

Why a Separate Resource System

Kotlin common code cannot see R.string.x (Android-only) or NSLocalizedString (Apple-only). Before CMP shipped resources, teams used a third-party library or duplicated the files per platform. The official system exists to remove that duplication and keep three promises:

  • One source of truth. A string translated once is translated on every platform.

  • Type safety. A missing or misspelt resource is a compile error against Res, not a runtime crash.

  • The same selection rules everywhere. Language, theme and density qualifiers behave like Android’s, even on iOS and the web.

Android res/ Apple asset catalogs and .strings Compose Multiplatform resources

Location

src/androidMain/res

Assets.xcassets, *.lproj in the Xcode project

src/commonMain/composeResources

Typed access

R.string.x

String(localized:) / generated symbols

Res.string.x

Language qualifier

values-es

es.lproj

values-es

Dark theme

drawable-night

Appearance slots in the asset catalog

drawable-dark

Density

drawable-xxhdpi

@2x / @3x

drawable-xxhdpi

Runs in common code

No

No

Yes

The trade-off is explicit: CMP resources are read by Compose. A SwiftUI screen over the shared module (the native-UI path described on Integrating Shared Code into iOS) keeps using Apple’s own localization — see Localization — and the legacy library at the end of this page.

Project Setup

Resources belong to the module that renders UI — sharedUI. Add the library dependency and the Gradle plugin (already applied by org.jetbrains.compose):

// sharedUI/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
    alias(libs.plugins.composeMultiplatform)              // org.jetbrains.compose
    alias(libs.plugins.composeCompiler)                   // org.jetbrains.kotlin.plugin.compose
}

kotlin {
    androidLibrary {
        namespace = "com.example.trailmate.shared.ui"
        compileSdk = 37
        minSdk = 26
    }
    jvm("desktop")
    iosArm64()
    iosSimulatorArm64()
    wasmJs { browser() }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.compose.runtime)
            implementation(libs.compose.foundation)
            implementation(libs.compose.material3)
            implementation(libs.compose.components.resources) // org.jetbrains.compose.components:components-resources
            implementation(project(":sharedLogic"))
        }
    }
}

compose.resources {
    packageOfResClass = "com.example.trailmate.shared.ui.resources"
    publicResClass = false
    generateResClass = auto
}

The compose.resources block controls how the Res class is generated:

Property Meaning Default

packageOfResClass

The Kotlin package of Res and its accessors. Set it explicitly; the default is derived from the Gradle group and module name and changes if you rename either.

Derived from the module

publicResClass

Make Res and the accessors public. Needed only if another module or an app needs to read this module’s resources.

false (internal)

generateResClass

auto generates Res when the module depends on the resources library, always forces it, never skips it.

auto

nameOfResClass

Rename the generated class (default Res); useful when two modules would otherwise clash.

Res

The common mistake here is leaving publicResClass = false and then trying to call Res.drawable.logo from androidApp. The accessor is internal to sharedUI; either expose a public @Composable wrapper from sharedUI (the cleaner design — the app module should only call App()) or flip the flag.

Android needs no androidResources configuration for these files: the Gradle plugin packages composeResources as assets, so the multiplatform Android library plugin from The Android Target and AGP 9 works unchanged. The generated accessors appear only after the first Gradle sync or build (generateComposeResClass), so a red Res in the IDE usually means the project has not been synced yet.

The composeResources Directory

Create the directory next to your Kotlin source: sharedUI/src/commonMain/composeResources. Its subfolders are not free-form; each name is a resource type:

Folder Contents Generated accessor

drawable

Raster images (png, jpg, webp, bmp) and vector images (svg, and Android vector xml)

Res.drawable.name (DrawableResource)

font

ttf and otf font files

Res.font.name (FontResource)

values

strings.xml with <string>, <plurals> and <string-array> elements

Res.string.name, Res.plurals.name, Res.array.name

files

Anything else, any subfolders — JSON, Lottie, text, a bundled .sqlite, audio

Read by path: Res.readBytes("files/…​")

TrailMate’s tree after the first few screens:

# sharedUI/src/commonMain/composeResources (layout of the directory)
composeResources/
  drawable/
    ic_favorite.xml              # Android vector drawable, converted at build time
    ic_trail.svg
    trail_placeholder.png
  drawable-dark/
    trail_placeholder.png        # used when the system theme is dark
  drawable-xxhdpi/
    hero_banner.png              # 3x density only; other densities fall back
  font/
    Inter-Regular.ttf
    Inter-Bold.ttf
  values/
    strings.xml                  # default language (English)
  values-es/
    strings.xml
  values-fr-rCA/
    strings.xml
  values-ar/
    strings.xml
  files/
    onboarding.json
    elevation_samples.csv

Names become identifiers. trail_placeholder.png is Res.drawable.trail_placeholder, so file names must be valid lowercase Kotlin identifiers made of letters, digits and underscores, with no dashes or spaces. Two files with the same name and different extensions in one folder are a build error. Every strings.xml key becomes a property of Res.string; keep keys identical across languages or the generated class (built from the default values folder) will not match.

Images with painterResource

painterResource returns a Painter for a DrawableResource. It decodes png/jpg/webp as bitmaps and svg or Android vector xml as vectors:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailRow.kt
package com.example.trailmate.ui.list

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.trailmate.domain.Trail
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.ic_favorite
import com.example.trailmate.shared.ui.resources.trail_placeholder
import com.example.trailmate.shared.ui.resources.trail_row_photo_description
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource

@Composable
fun TrailRow(trail: Trail, modifier: Modifier = Modifier) {
    Row(modifier) {
        Image(
            painter = painterResource(Res.drawable.trail_placeholder),
            contentDescription = stringResource(Res.string.trail_row_photo_description, trail.name),
            modifier = Modifier.size(64.dp),
        )
        Text(trail.name)
        if (trail.isFavorite) {
            Icon(painterResource(Res.drawable.ic_favorite), contentDescription = null)
        }
    }
}

Notes that save debugging time:

  • Imports are per resource. Accessors are top-level extension properties in the Res package, so each Res.drawable.x needs import …​resources.x. The IDE adds them; a wildcard import of the package also works.

  • Variants of the call. painterResource picks the right painter type automatically. vectorResource(…​) returns an ImageVector and imageResource(…​) an ImageBitmap when you need those exact types, for example for Icon(imageVector = …​) or Canvas drawing.

  • Loading is asynchronous on some targets. On the web, a resource that is not yet fetched renders as an empty painter for a frame or more; see the web section below for preloading.

  • SVG support is a subset. Gradients, clip paths and simple paths work; filters, masks and CSS-styled SVGs do not. Convert complex artwork to png or to an Android vector.

For remote photos (TrailMate’s photoUrl) use an image-loading library rather than a resource, as discussed on Essential Multiplatform Libraries; the bundled trail_placeholder is the loading and error image.

Strings, Plurals and String Arrays

values/strings.xml uses the same schema as Android, so existing files copy straight over:

<!-- sharedUI/src/commonMain/composeResources/values/strings.xml -->
<resources>
    <string name="app_name">TrailMate</string>
    <string name="trail_list_title">Trails near you</string>
    <string name="trail_row_photo_description">Photo of %1$s</string>
    <string name="trail_length">%1$s km</string>
    <string name="settings_title">Settings</string>
    <string name="disclaimer" translatable="false">Not for navigation.</string>

    <plurals name="trail_count">
        <item quantity="one">%1$d trail found</item>
        <item quantity="other">%1$d trails found</item>
    </plurals>

    <string-array name="difficulty_labels">
        <item>Easy</item>
        <item>Moderate</item>
        <item>Hard</item>
    </string-array>
</resources>
API Context Returns

stringResource(Res.string.x, args…​)

@Composable

The String, recomposed when the locale changes

getString(Res.string.x, args…​)

suspend; any coroutine (a ViewModel, a notification builder)

The String

pluralStringResource(Res.plurals.x, quantity, args…​)

@Composable

The plural form chosen for quantity

getPluralString(Res.plurals.x, quantity, args…​)

suspend

The plural form

stringArrayResource(Res.array.x)

@Composable

List<String>

getStringArray(Res.array.x)

suspend

List<String>

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailListScreen.kt
package com.example.trailmate.ui.list

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.difficulty_labels
import com.example.trailmate.shared.ui.resources.trail_count
import com.example.trailmate.shared.ui.resources.trail_length
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource

@Composable
fun TrailSummary(count: Int, lengthKm: Double, difficultyOrdinal: Int) {
    // quantity picks the form, the trailing argument fills %1$d
    Text(pluralStringResource(Res.plurals.trail_count, count, count))
    Text(stringResource(Res.string.trail_length, lengthKm.toString()))
    Text(stringArrayResource(Res.array.difficulty_labels)[difficultyOrdinal])
}

Points that surprise newcomers:

  • Format arguments are positional. Use %1$s, %2$d (never a bare %s when a translation may reorder words). getString and stringResource pass the arguments in order; a count mismatch fails at runtime.

  • Plural arguments repeat the number. pluralStringResource(plural, quantity, args) uses quantity only to choose the form; if the text shows the number you pass it again as an argument.

  • Quantity categories are language-specific. English needs one and other; Arabic uses zero, one, two, few, many and other; French treats 0 as one. Only the locale’s CLDR categories are consulted, so other must always exist.

  • getString belongs in non-UI code. A shared ViewModel should expose a state or a StringResource key, not a pre-translated String, so the UI re-resolves it when the language changes. Resolving at the UI edge also keeps the ViewModel testable without resources; see Architecture and Shared ViewModels.

  • Special characters follow Android escaping. Escape an apostrophe as \' or wrap the text in quotes, escape @ and ? at the start, and write & and < in XML.

Passing a resource key instead of text looks like this:

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListUiState.kt
// sharedLogic has no Compose dependency, so the UI layer maps an enum to a StringResource.
package com.example.trailmate.presentation

sealed interface TrailListMessage {
    data object Empty : TrailListMessage
    data class Count(val value: Int) : TrailListMessage
    data class NetworkError(val retryInSeconds: Int) : TrailListMessage
}

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailListMessageText.kt
package com.example.trailmate.ui.list

import androidx.compose.runtime.Composable
import com.example.trailmate.presentation.TrailListMessage
import com.example.trailmate.shared.ui.resources.*
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource

@Composable
fun TrailListMessage.asText(): String = when (this) {
    TrailListMessage.Empty -> stringResource(Res.string.trail_list_empty)
    is TrailListMessage.Count -> pluralStringResource(Res.plurals.trail_count, value, value)
    is TrailListMessage.NetworkError -> stringResource(Res.string.network_error_retry, retryInSeconds)
}

This design keeps the strongly typed domain in sharedLogic and the presentation in sharedUI, and it is also what lets the SwiftUI path of the same app map TrailListMessage to its own String(localized:) values.

Fonts

Fonts are the one resource that cannot be a plain Painter: a Font in CMP is resolved inside composition, because it depends on the weight and style at the call site and may need loading. Put ttf/otf files in font/ and build a FontFamily with the composable Font function:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/theme/Type.kt
package com.example.trailmate.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import com.example.trailmate.shared.ui.resources.Inter_Bold
import com.example.trailmate.shared.ui.resources.Inter_Regular
import com.example.trailmate.shared.ui.resources.Res
import org.jetbrains.compose.resources.Font

@Composable
fun trailMateTypography(): Typography {
    val inter = FontFamily(
        Font(Res.font.Inter_Regular, weight = FontWeight.Normal),
        Font(Res.font.Inter_Bold, weight = FontWeight.Bold),
    )
    return Typography(
        bodyLarge = TextStyle(fontFamily = inter, fontSize = 16.sp),
        titleLarge = TextStyle(fontFamily = inter, fontWeight = FontWeight.Bold, fontSize = 22.sp),
    )
}

Because trailMateTypography() is @Composable, it must be called from inside TrailMateTheme; building it in a top-level val does not compile. Glyph fallback differs by platform: Android, iOS and desktop fall back to system fonts for missing glyphs (emoji, CJK), but a browser build does not ship a system emoji or CJK font to Skia, so bundle a fallback font or you will see empty boxes (see the web section). Theming itself — MaterialTheme, colour schemes — is covered on Compose Material 3 and Theming and applies unchanged.

Raw Files with Res.readBytes and Res.getUri

Everything under files/ is opaque to the generator. Read it by its path relative to composeResources:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/onboarding/OnboardingLoader.kt
package com.example.trailmate.ui.onboarding

import com.example.trailmate.shared.ui.resources.Res
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.Serializable

@Serializable
data class OnboardingPage(val title: String, val body: String)

class OnboardingLoader(private val json: Json = Json) {
    // suspend: reading may hit disk, the APK, the app bundle or the network (web)
    suspend fun load(): List<OnboardingPage> {
        val bytes = Res.readBytes("files/onboarding.json")
        return json.decodeFromString(bytes.decodeToString())
    }
}

// elsewhere: a URI string that a platform component (video player, WebView) can open
fun elevationCsvUri(): String = Res.getUri("files/elevation_samples.csv")
  • Res.readBytes(path) is a suspend function returning a ByteArray. In a composable, wrap it in LaunchedEffect or produceState; do not call it on the main thread via runBlocking, which blocks the browser entirely.

  • Res.getUri(path) returns a platform URI: a file: or jar: URI on desktop, file:///android_asset/…​; on Android and a URL relative to the page on the web. Pass it to components that take a URI (an audio player, a WebView), not to java.io.File.

  • Paths are case-sensitive and use forward slashes on every platform; a typo is a runtime exception, not a compile error (unlike typed accessors for drawables and strings). Wrap the constants in one object.

  • Res.allDrawableResources, Res.allStringResources and similar maps expose all generated resources by name, for data-driven UIs (an icon chosen from a server response). They exist for the same reason as Resources.getIdentifier on Android, and carry the same caveat: names are not refactor-safe.

Qualifiers and How They Are Matched

Any resource folder may carry qualifiers after a dash: values-es, drawable-dark, drawable-xxhdpi, drawable-es-dark-xhdpi. The runtime reads the resource environment — the current language and region, the system theme and the screen density — and picks the best folder.

Qualifier kind Syntax Examples

Language

ISO 639-1 code

-en, -es, -fr

Region (needs language)

-r plus ISO 3166-1 code

-fr-rCA, -pt-rBR

Script (BCP 47)

-b+language+Script+Region

-b+zh+Hant+TW

Theme

-light, -dark

drawable-dark

Density

-ldpi, -mdpi, -hdpi, -xhdpi, -xxhdpi, -xxxhdpi

drawable-xxhdpi

The priority is fixed and differs from Android’s longer list: language and region first, then theme, then density. That means a Spanish dark-theme drawable (drawable-es-dark) beats a plain English dark one, which beats a plain drawable-es, and so on. Density is the last tiebreaker, which is why a missing density still works: the runtime picks the closest available bucket and scales.

# sharedUI/src/commonMain/composeResources (matching for the file hero_banner.png)
drawable/hero_banner.png                  # fallback for everything
drawable-dark/hero_banner.png             # dark theme, any language
drawable-es/hero_banner.png               # Spanish, any theme
drawable-es-dark/hero_banner.png          # Spanish AND dark: wins for Spanish + dark
drawable-xxhdpi/hero_banner.png           # last tiebreaker among otherwise equal matches

Common mistakes with qualifiers:

  • A qualified resource without a default. The generator builds Res from the default folders; an image that exists only in drawable-dark is invisible to the generated class. Always provide the unqualified base file.

  • -pt-BR instead of -pt-rBR. The region is written -rBR. The BCP 47 form -b+pt+BR is the unambiguous alternative.

  • Expecting runtime theme change from isSystemInDarkTheme(). Resource theme matching follows the system theme. If TrailMate has an in-app theme switch (a UserPreferencesRepository value), a drawable-dark image will not follow it. Choose the image in code from your own theme state, as the preferences setup on Local Storage with SQLDelight, Room and DataStore shows, or use a tintable vector.

  • Treating density as Android-only. iOS and the web report a density too; supply drawable-xxhdpi rather than relying on one large file everywhere to avoid scaling cost and memory.

Resources in Libraries and Non-Common Source Sets

Resource directories are attached to source sets, not only to commonMain:

  • src/commonMain/composeResources is merged into every target.

  • src/androidMain/composeResources, src/iosMain/composeResources, src/desktopMain/composeResources (the jvm target named desktop) and src/wasmJsMain/composeResources contribute only to that target and are merged on top of common resources: a file with the same path replaces the common one.

That is how TrailMate ships a smaller launcher artwork on desktop or a platform-specific files/mapStyle.json without touching common code.

// sharedUI/build.gradle.kts (extra resource directory for one source set)
compose.resources {
    customDirectory(
        sourceSetName = "desktopMain",
        directoryProvider = provider { layout.projectDirectory.dir("desktopResources") },
    )
}

Libraries that publish composables with bundled images or strings (a design system, a map-attribution component) publish their composeResources inside their artifact, and CMP merges them into the consuming app. Each library has its own Res class in its own package, so there is no clash; that is why packageOfResClass must be unique and stable. A consumer reads a library’s resource only if that library set publicResClass = true; otherwise the library keeps the resource internal and exposes a composable that uses it.

Situation Where the resource lives Access

App-private image used by one module

That module’s commonMain/composeResources

internal Res

Image shared by sharedUI and desktopApp

sharedUI, with publicResClass = true

Res from the app module

Branding of a published design-system library

The library’s composeResources, publicResClass = true

Consumer imports the library’s Res

One platform needs different artwork

<target>Main/composeResources with the same path

Same Res accessor

Do not put resources that Compose never reads (an Android NotificationChannel icon, the launcher icon, iOS LaunchScreen) here: those remain platform resources, in androidApp/src/main/res and the Xcode asset catalog of iosApp, because the OS reads them before Kotlin runs.

Localization

Adding a language is adding a folder: copy values/strings.xml to values-es/strings.xml and translate it.

<!-- sharedUI/src/commonMain/composeResources/values-es/strings.xml -->
<resources>
    <string name="app_name">TrailMate</string>
    <string name="trail_list_title">Rutas cerca de ti</string>
    <string name="trail_row_photo_description">Foto de %1$s</string>
    <plurals name="trail_count">
        <item quantity="one">%1$d ruta encontrada</item>
        <item quantity="other">%1$d rutas encontradas</item>
    </plurals>
</resources>

A key missing from values-es falls back to values, so a partial translation never crashes. Which language is chosen is decided by the platform, and that is where the per-platform setup lives:

Platform How the system language reaches Compose

Android

The app locale (per-app language settings on Android 13+ and the system list); recreated on change.

iOS

The preferred languages of the app. Declare the languages you ship in Info.plist with CFBundleLocalizations; without it iOS reports only the development language for the app and CMP will not see Spanish.

Desktop (JVM)

Locale.getDefault()

Web

navigator.language and navigator.languages

<!-- iosApp/iosApp/Info.plist (declare the languages the app supports) -->
<key>CFBundleLocalizations</key>
<array>
    <string>en</string>
    <string>es</string>
    <string>fr-CA</string>
    <string>ar</string>
</array>

This is the Apple rule described on Localization and the Android rule on Resources, Configuration, Localization and Themes; CMP reads their result, it does not replace it.

Changing the App Language at Runtime with LocalAppLocale

By default the app follows the system language. A "Language" row in TrailMate’s SettingsScreen needs the opposite: an in-app override. The official guidance is a small expect/actual LocalAppLocale composition local that sets the platform’s default locale and forces recomposition:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.kt
package com.example.trailmate.ui.locale

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue

expect object LocalAppLocale {
    /** The current language tag, for example "es" or "fr-CA". */
    val current: String @Composable get

    /** Override the locale; pass null to follow the system again. */
    @Composable
    infix fun provides(value: String?): ProvidedValue<*>
}
// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.android.kt
package com.example.trailmate.ui.locale

import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import java.util.Locale

actual object LocalAppLocale {
    private var default: Locale? = null
    private val LocalAppLocaleImpl = staticCompositionLocalOf { Locale.getDefault().toLanguageTag() }

    actual val current: String
        @Composable get() = LocalAppLocaleImpl.current

    @Composable
    actual infix fun provides(value: String?): ProvidedValue<*> {
        val configuration = LocalConfiguration.current
        if (default == null) default = Locale.getDefault()
        val new = value?.let(Locale::forLanguageTag) ?: default!!
        Locale.setDefault(new)
        configuration.setLocale(new)
        val resources = LocalContext.current.resources
        resources.updateConfiguration(configuration, resources.displayMetrics)
        return LocalAppLocaleImpl.provides(new.toLanguageTag())
    }
}
// sharedUI/src/iosMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.ios.kt
package com.example.trailmate.ui.locale

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.staticCompositionLocalOf
import platform.Foundation.NSLocale
import platform.Foundation.NSUserDefaults
import platform.Foundation.preferredLanguages

actual object LocalAppLocale {
    private val default = NSLocale.preferredLanguages.firstOrNull() as? String
    private val LocalAppLocaleImpl = staticCompositionLocalOf { default ?: "en" }

    actual val current: String
        @Composable get() = LocalAppLocaleImpl.current

    @Composable
    actual infix fun provides(value: String?): ProvidedValue<*> {
        val new = value ?: default
        if (value == null) {
            NSUserDefaults.standardUserDefaults.removeObjectForKey("AppleLanguages")
        } else {
            NSUserDefaults.standardUserDefaults.setObject(listOf(new), forKey = "AppleLanguages")
        }
        return LocalAppLocaleImpl.provides(new ?: "en")
    }
}

The desktop actual calls Locale.setDefault and the web actual stores the tag in localStorage and reads navigator.language initially; both follow the same shape. Use the local at the root of App() and wrap the content in key(…​) so that every stringResource and every cached text re-reads the new language:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/App.kt
package com.example.trailmate

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import com.example.trailmate.settings.UserPreferencesRepository
import com.example.trailmate.ui.locale.LocalAppLocale
import com.example.trailmate.ui.theme.TrailMateTheme
import org.koin.compose.koinInject

@Composable
fun App(preferences: UserPreferencesRepository = koinInject()) {
    val languageTag by preferences.languageTag.collectAsState(initial = null)
    CompositionLocalProvider(LocalAppLocale provides languageTag) {
        key(LocalAppLocale.current) {            // recompose the whole tree on change
            TrailMateTheme {
                TrailMateNavHost()
            }
        }
    }
}

This is a user-implemented pattern, not a built-in setter, so its behaviour is under your control: on Android it changes the process-wide Locale.getDefault(), so non-Compose code in the same process sees it too. On iOS the AppleLanguages override only takes effect on the next launch for UIKit and SwiftUI parts of the app (relevant when screens are mixed as described on Compose Multiplatform on iOS); Compose reads it directly. Android 13+ offers a system per-app language UI (LocaleManager) as a platform alternative, and it works with CMP because Compose reads the resulting configuration.

Regional Formats

Language and region are separate settings. A user can run English with Germany’s formats (decimal comma, 24-hour time, metric units, Monday as first day). CMP’s stringResource handles text only; numbers, dates and units are your code’s job, and they must follow the region, not just the language.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.kt
package com.example.trailmate.ui.format

import androidx.compose.runtime.Composable

/** Formats a length in the user's regional convention (km or miles, locale digits). */
expect class LengthFormatter() {
    fun format(lengthKm: Double): String
}

// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.android.kt
// (desktopMain uses the same java.text code)
actual class LengthFormatter actual constructor() {
    actual fun format(lengthKm: Double): String {
        val locale = java.util.Locale.getDefault()
        val miles = locale.country in setOf("US", "GB", "LR", "MM")
        val value = if (miles) lengthKm * 0.621371 else lengthKm
        val nf = java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 1 }
        return nf.format(value) + if (miles) " mi" else " km"
    }
}

// sharedUI/src/iosMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.ios.kt
actual class LengthFormatter actual constructor() {
    actual fun format(lengthKm: Double): String {
        val measurement = platform.Foundation.NSMeasurement(lengthKm, platform.Foundation.NSUnitLength.kilometers)
        val formatter = platform.Foundation.NSMeasurementFormatter().apply {
            unitOptions = platform.Foundation.NSMeasurementFormatterUnitOptionsNaturalScale
        }
        return formatter.stringFromMeasurement(measurement)
    }
}

Two rules follow. First, never hand-format dates or numbers with toString() or string concatenation in a shared composable; use kotlinx-datetime for the model and a platform formatter (as above, or the Intl APIs behind a wasmJs actual) for presentation — the libraries are listed on Essential Multiplatform Libraries. Second, test the shared code in a non-English region: the classic bug is a Double printed as 12.5 and parsed back as 12,5. For the full pattern of platform differences, see expect/actual and Platform APIs.

Right-to-Left Layouts

Compose derives LocalLayoutDirection from the current locale, so a row that places an icon before text puts it on the right in Arabic and Hebrew automatically: Row, padding(start=..), Arrangement.Start and Alignment.Start all mirror.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/detail/DifficultyBadge.kt
package com.example.trailmate.ui.detail

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.ic_arrow_forward
import org.jetbrains.compose.resources.painterResource

@Composable
fun DifficultyBadge(label: String) {
    val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
    Row(Modifier.padding(start = 8.dp)) {        // "start" mirrors; "left" would not
        Text(label)
        Icon(
            painterResource(Res.drawable.ic_arrow_forward),
            contentDescription = null,
            modifier = Modifier.scale(scaleX = if (rtl) -1f else 1f, scaleY = 1f), // directional glyph
        )
    }
}

Rules of thumb:

  • Mirror directional icons only. A back arrow or a forward chevron flips; a play button, a clock or a map pin does not.

  • Use start/end, never left/right, in padding, alignment and absolute offsets.

  • Numbers stay left-to-right inside right-to-left text; keep digit grouping to the formatter.

  • Test with a pseudolocale or a real RTL language. Add an ar folder even if you do not ship it yet; it exposes truncation and alignment bugs early.

  • On iOS, make sure ar is listed in CFBundleLocalizations; otherwise iOS reports an LTR environment.

  • Combined with text, always pass the translated string through the same Text, never concatenate fragments, so that word order can change.

Localization Tests

Because Res is plain Kotlin, resource behaviour is testable in common code with the UI test library already used by TrailMate’s TrailListScreenTest. The test below renders the list in Spanish and asserts the plural form:

// sharedUI/src/commonTest/kotlin/com/example/trailmate/ui/list/TrailListLocalizationTest.kt
package com.example.trailmate.ui.list

import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import com.example.trailmate.ui.locale.LocalAppLocale
import kotlin.test.Test

class TrailListLocalizationTest {
    @OptIn(ExperimentalTestApi::class)
    @Test
    fun countIsPluralisedInSpanish() = runComposeUiTest {
        setContent {
            CompositionLocalProvider(LocalAppLocale provides "es") {
                TrailSummary(count = 3, lengthKm = 12.5, difficultyOrdinal = 1)
            }
        }
        waitForIdle()
        onNodeWithText("3 rutas encontradas").assertIsDisplayed()
    }
}

runComposeUiTest is Experimental (@ExperimentalTestApi). It runs on the desktop and Android host targets and, with a simulator, on iOS; the general test layout is on Essential Multiplatform Libraries and the Android equivalent on Instrumented and UI Testing. Beyond rendered text, two cheap checks pay off:

  • Key parity. A unit test or a CI script that compares the keys of every values-* folder against values catches an untranslated key before release. The default folder is the contract; the others must have the same keys.

  • Format-argument parity. Compare the %1$s placeholders per key between languages; a translation that drops one crashes at runtime.

Web: Preloading and Caching

On the web (Kotlin/Wasm, Beta) every resource is a separate fetch against the files that wasmJsBrowserDistribution copies next to the application, under composeResources/. That has two consequences:

  • The first frame can render before the resource arrives. Text from strings.xml is requested before it can be drawn, an image shows nothing, and a custom font momentarily falls back to a default font (a flash of unstyled text).

  • A missing fallback font means missing glyphs. Skia renders text itself, so emoji and CJK characters need a font that you bundle.

CMP offers preloading helpers (Experimental, @ExperimentalResourceApi) that fetch the resource inside a composable and return a state, so the app can show a splash screen until they are ready:

// webApp/src/wasmJsMain/kotlin/com/example/trailmate/Main.kt
package com.example.trailmate

import androidx.compose.runtime.getValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.compose.material3.CircularProgressIndicator
import com.example.trailmate.shared.ui.resources.Inter_Regular
import com.example.trailmate.shared.ui.resources.NotoColorEmoji
import com.example.trailmate.shared.ui.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.preloadFont

@OptIn(ExperimentalComposeUiApi::class, ExperimentalResourceApi::class)
fun main() {
    ComposeViewport("composeApplication") {
        val inter by preloadFont(Res.font.Inter_Regular)
        val emoji by preloadFont(Res.font.NotoColorEmoji)
        if (inter != null && emoji != null) {
            App()
        } else {
            CircularProgressIndicator()   // shown until both fonts are fetched
        }
    }
}

The same document describes preloadImageBitmap and preloadImageVector for large images. For caching, the resource files are plain static files: serve them with long-lived Cache-Control headers and content-hashed names or a versioned path, and set a short cache time on the HTML entry point so a new release is picked up. A service worker can precache the composeResources folder for offline use. GitHub Pages sets modest cache headers, so a changed resource may take a while to appear on repeat visits after a release, which is a common surprise. The target-specific details are in Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.

moko-resources: The Legacy Approach

Before CMP resources, the community library moko-resources (dev.icerock.moko:resources) was the way to share strings, images, fonts and files from common code: a Gradle plugin generated a MR object with StringResource and ImageResource and platform actuals read them. Its model is the ancestor of the one above, and it is still maintained.

moko-resources CMP resources

Works with SwiftUI and Android Views

Yes: resources convert to NSString/UIImage or Android getString

Only inside Compose

Compose support

Optional add-on module

Built in

Official support

Community

JetBrains

Typed accessors

MR.strings.x

Res.string.x

Use CMP resources for every Compose UI. Reach for moko-resources only when you share strings with native UI (a SwiftUI screen over the shared module) and want one translation file; even then, many teams keep native localization per platform instead, because each platform’s tooling (Xcode string catalogs, Play Console, translators' tools) already understands it. Migrating from moko-resources is mostly moving files into composeResources, converting MR.strings.x to Res.string.x, and replacing stringResource(MR.strings.x) with the CMP overload.

Common Mistakes

  • Using compose.components.resources accessor in a catalog-less build. The Gradle accessor compose.components.resources is deprecated; use the direct org.jetbrains.compose.components:components-resources coordinates with the same version as the Compose plugin.

  • Renaming the module without setting packageOfResClass. Every import changes; set it once, explicitly.

  • strings.xml outside values. Only values and values-<qualifiers> are read; a file in composeResources/strings.xml is ignored.

  • Resource names with dashes or capitals that Kotlin rejects. Use snake_case; fonts such as Inter-Bold.ttf must be renamed to Inter_Bold.ttf.

  • Forgetting CFBundleLocalizations. The app runs in English on iOS regardless of the phone’s language.

  • Blocking in Res.readBytes. It is suspend on every target; do not wrap it in runBlocking.

  • Putting the launcher icon or notification icon in composeResources. The OS reads those before Kotlin starts.

  • Caching a translated String in a ViewModel. It will not change when the language does.

Summary

Need Use

One resource system on all targets

composeResources + generated Res (Stable)

Image

painterResource(Res.drawable.x)

Text, plural, list

stringResource, pluralStringResource, stringArrayResource; getString off the UI thread

Custom font

Font(Res.font.x) in a FontFamily, inside a composable

Bundled data file

Res.readBytes("files/…​"), Res.getUri("files/…​")

Variants

Folder qualifiers; priority is language/region, then theme, then density

In-app language

LocalAppLocale expect/actual and key(…​)

Regional formats

Platform formatters behind expect/actual

RTL

start/end, mirror only directional icons

Web

Preload fonts and images (Experimental); bundle fallback fonts

Native UI strings shared with Swift

moko-resources (legacy) or per-platform localization

For what comes next: navigation, lifecycle and ViewModels on Lifecycle, ViewModel and Navigation in Compose Multiplatform; sizing, popups and accessibility on Layouts, Material 3, Adaptive UI, Popups and Accessibility; and the desktop target, with its own packaging of these resources, on Compose Multiplatform for Desktop.