Custom Fonts and Typography in Jetpack Compose

Quick answer: put licensed font files in
app/src/main/res/font, create aFontFamilythat maps every file to its realFontWeightandFontStyle, then apply that family through named Material 3Typographystyles. Feature composables should useMaterialTheme.typographyrather than setting a family, size, and weight on everyText.
A font is not merely a visual swap. It changes letter widths, line wrapping, baseline positions, perceived emphasis, and how much room labels need at large font scales. Treat custom typography as theme data and test it with realistic content—not as decoration added to a title at the end of a screen.
The Compose font guide documents the resource-based path: bundle files in res/font, load them with Font, group them in a FontFamily, and use the family in Compose text styles.
Choose the right font-loading strategy
For most product UI, bundled fonts are the dependable starting point.
| Strategy | Best for | Trade-off |
|---|---|---|
System family such as FontFamily.SansSerif | A platform-native look with no new assets | Less brand distinction and device-dependent rendering details |
| Bundled static font files | Reliable offline rendering and known weights | Adds font files to the app bundle |
| Downloadable Google Fonts | Reducing bundled font size when the provider path fits the product | Needs the Google Fonts dependency, provider setup, and a local fallback |
| Bundled variable font | Several variations from one file on supported Android versions | Requires an API 26+ fallback and careful axis choices |
Do not use an unlicensed font simply because it is available to download. Confirm the license covers mobile-app embedding and preserve any attribution requirements before adding the files to the project.
Add bundled font files under res/font
Place the files in the Android app module, not in Compose source code:
app/
src/
main/
res/
font/
atkinson_hyperlegible_regular.ttf
atkinson_hyperlegible_italic.ttf
atkinson_hyperlegible_bold.ttfAndroid resource names must be lowercase and use underscores. The resource directory gives the files stable generated IDs such as R.font.atkinson_hyperlegible_regular.
Choose the exact files you intend to request. If the UI asks for FontWeight.SemiBold but the family only supplies normal and bold files, Compose must resolve the closest available face. That can be a reasonable fallback, but it is not the same as shipping the intended semi-bold design.
Build a FontFamily with real weights and styles
Define a family once in a theme file such as ui/theme/Type.kt. Each Font entry names the resource and the face it actually represents.
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
val AppFontFamily = FontFamily(
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
Font(
R.font.atkinson_hyperlegible_italic,
weight = FontWeight.Normal,
style = FontStyle.Italic,
),
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
)Do not label a regular file as SemiBold simply to make a Text call look heavier. The mapping is metadata Compose uses to choose a face. A wrong map makes text weight inconsistent and can cause the platform to synthesize a style instead of using the typeface you meant to ship.
The same family can be used directly for a one-off text experiment:
Text(
text = "Account settings",
fontFamily = AppFontFamily,
fontWeight = FontWeight.Bold,
)For app UI, however, put that decision into a typography scale so every screen uses the same language.
Define a Material 3 typography scale
Material 3 groups its named styles into display, headline, title, body, and label, each with large, medium, and small variants. Use the name that describes the text’s role; avoid treating every heading as a custom 24.sp and every label as a custom 13.sp.
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
headlineMedium = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp,
),
titleLarge = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
bodyLarge = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelLarge = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
lineHeight = 20.sp,
),
)This is a deliberately small scale. The Typography constructor supplies defaults for styles you do not override, so a product does not need to customize all 15 Material 3 roles to start with. The Material 3 Compose guidance documents the default scale and shows how individual TextStyle values define a custom type system.
Use sp for text size and line height. It respects the user’s font-size preference. Do not convert text sizes to dp to force a layout to fit; fix the layout, wording, or information hierarchy instead.
Provide the scale through MaterialTheme
Pass the typography at the same app boundary where you supply color and shapes:
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
typography = AppTypography,
content = content,
) {
content()
}
}Then feature code only needs the role it wants to communicate:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun AccountHeader(name: String) {
Text(
text = name,
style = MaterialTheme.typography.titleLarge,
)
}This separation is valuable when brand typography changes. Updating AppTypography updates every component using that role, while a screen that hard-codes a separate font family silently drifts away from the system. For the bigger color/shape/theme contract, see MaterialTheme in Jetpack Compose.
Set line height and letter spacing deliberately
Changing a family without reviewing line height is one of the most common typography bugs. Two fonts at 16.sp can have very different ascenders, descenders, and internal spacing. A line height that looked comfortable with one family may feel cramped or overly loose with another.
Use lineHeight on a named style when reading rhythm is important:
bodyLarge = TextStyle(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
)Letter spacing is another type-scale choice, not a repair tool for text that does not fit. Over-tightening body text or widening every label can reduce readability. Change it only when the typeface, style, and content role justify the adjustment, then review localized and large-font text.
Typography and color work together to establish emphasis. Use a named type role and a semantic color role rather than compensating for weak hierarchy with arbitrary font sizes. ColorScheme in Material 3 explains how onSurface and onSurfaceVariant support that pairing.
Use fonts inside reusable components without locking the caller out
A reusable component should receive the style that matches its content role or inherit the caller’s current text style. Avoid embedding a screen-specific custom family inside a low-level component.
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
@Composable
fun SectionTitle(
text: String,
style: TextStyle = MaterialTheme.typography.titleLarge,
) {
Text(
text = text,
style = style,
)
}The default communicates the app’s normal section-title style, while the parameter lets a product use the same component in a smaller context without duplicating its structure. The same principle applies to Modifier: reusable UI should expose the narrowest customization point that callers genuinely need. See Jetpack Compose Modifier: A Practical Guide for that API pattern.
Downloadable fonts: use them only with a fallback plan
Compose supports asynchronous downloadable Google Fonts through the ui-text-google-fonts artifact. This can reduce bundled font size, but it adds provider configuration, certificate verification, and a loading/failure path. The official font guide notes that custom downloadable-font providers are not currently supported by Compose.
For a font that affects core reading and navigation UI, bundled files are usually simpler because the typeface is available offline from the first frame. If you choose the downloadable route, put an equivalent local resource after each downloadable font in the family’s weight chain:
val BodyFontFamily = FontFamily(
androidx.compose.ui.text.googlefonts.Font(
googleFont = GoogleFont("Lora"),
fontProvider = provider,
weight = FontWeight.Normal,
),
Font(R.font.lora_regular, FontWeight.Normal),
androidx.compose.ui.text.googlefonts.Font(
googleFont = GoogleFont("Lora"),
fontProvider = provider,
weight = FontWeight.Bold,
),
Font(R.font.lora_bold, FontWeight.Bold),
)Compose tries the online font first and then the local fallback for the matching weight. Read the downloadable-font and fallback documentation before adopting this path; it also documents provider availability checks and failure debugging.
The snippet is illustrative: it assumes that provider has been configured with the Google Fonts authority, package, and certificate resources described in the official guide. Keep the Font imports unambiguous—one comes from androidx.compose.ui.text.font, the other from androidx.compose.ui.text.googlefonts.
Variable fonts are an advanced, API-gated option
A variable font can hold several weights, widths, or slants in a single file. It can reduce the number of font files, but it does not remove design decisions: choose only axes the font actually supports and test every configured combination.
Compose’s variable-font API is experimental, and Android’s official guidance states that variable fonts require Android 8.0 (API 26) or higher. Use an API guard plus a static fallback for earlier versions. Variable fonts are not currently supported through Compose downloadable fonts, so this is a bundled-resource technique.
Use it when the product has a clear typographic requirement. Do not add it merely because a variable font exists; regular files with normal and bold weights are easier to review and often sufficient.
Test text as content, not as a screenshot
Before shipping a custom family, check the typography in the states users actually encounter:
- Light and dark theme, if the app supports both.
- System font scale at least at the largest size your product supports.
- Long localized strings, scripts your chosen font may not cover, and emoji fallback.
- Narrow widths, split-screen, dialogs, and buttons with long labels.
- Bold, italic, disabled, selected, and error states.
- Real baseline alignment when text shares a row with icons or a different text size.
At a large font scale, allow text to wrap or let a control grow in height rather than clipping it. If typography needs a precise baseline relationship, Baseline Alignment and Intrinsic Measurements explains the layout tools that belong to that job.
Common mistakes to avoid
Styling every Text independently
Repeated fontFamily, fontSize, and fontWeight arguments make an app difficult to tune. Put product defaults in Typography; reserve local overrides for a genuine exception.
Mapping a missing weight to the wrong file
If the design needs a semi-bold face, ship that face or choose an available weight intentionally. Mislabeling a resource hides the missing asset and creates inconsistent rendering.
Forgetting fallback glyphs
No single display font covers every language, symbol, or emoji. Review the actual locales your app supports and make sure the fallback result is readable. A decorative display family may be right for a headline but wrong for body text or input fields.
Treating maxLines = 1 as a layout fix
Single-line truncation can be appropriate for compact controls, but it should be a deliberate design choice. Do not silently ellipsize important messages just because a new font is wider than the old one.
A practical typography rule
Bundle and map the font files you actually use, describe text with Material 3 type roles, and provide those roles through MaterialTheme. Let user font scale and real content influence the layout. That gives custom typography a consistent, accessible place in the app rather than turning it into a collection of fragile per-screen overrides.