
Styling text in Jetpack Compose sounds simple… until it isn’t. In this post, we’ll explore the limitations of AnnotatedString and how StyledString makes rich text way easier to manage. Let’s break it down 👇
📚 Table of Contents
- Introduction: A Bold Word, a Link, and a Whole Lot of Trouble
- AnnotatedString: Too Much Style, Not Enough Simplicity
- Introducing StyledString: One API to Style Them All
- StyledString Under the Hood: The Engine Behind the API
- Final Thoughts
Introduction: A Bold Word, a Link, and a Whole Lot of Trouble
At first, you’ve got AnnotatedString, a SpanStyle, and everything feels smooth. You want to bold a word? Easy ✅. Underline something? No problem. It even feels kind of fun, especially when you’re building the whole string manually, like in the official docs.
But here’s the thing 🧠
That works great when you’re in full control of the string. The moment you’re dealing with real-world content: dynamic copy, localized text, paragraphs passed in from somewhere else, and you need to style just part of it?
Things get ugly. Fast.
Suddenly you’re tracking substrings, calculating indices, applying styles, and wiring up click listeners. All it takes is one change to the text, and your logic falls apart like a house of cards 🃏
All you wanted was to bold a word and make a link clickable. Now you’re knee-deep in boilerplate, praying nothing shifts.
In this post, I’ll walk through why AnnotatedString doesn’t scale well in real UIs, and introduce a tiny abstraction I built to fix that. It’s called StyledString💡And it does one thing really well:
Make text styling in Compose simple again.
AnnotatedString: Too Much Style, Not Enough Simplicity
Let’s give AnnotatedString some credit first. It’s a powerful tool 💪
You can create styled, clickable, interactive text using a single Text composable. Want to make one word bold and another act like a link? Totally possible. The API is flexible, low-level, and backed by the same rich text engine that powers Compose itself.
The problem is, it works best when you’re building the entire string manually.
Most examples in the docs look like this:
| buildAnnotatedString { | |
| append("Hello ") | |
| withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { | |
| append("world") | |
| } | |
| } |
Pretty nice, right? But here’s where it gets tricky 👀
What if you have a full block of dynamic text, like a localized string or a sentence pulled from somewhere else, and you just want to style part of it?
Now you’re dealing with:
- Finding the substring you want to style
- Calculating start and end indices
- Manually adding styles or annotations
- Hoping the text never changes or everything will fall apart
And if you need multiple styles, like a bold word, a clickable email, and an underlined URL, dynamically, things get messy fast 🔥
At that point, buildAnnotatedString turns into a mix of brittle index math, repeated logic, and boilerplate that’s hard to read and even harder to maintain.
Sure, AnnotatedString is powerful. But when your text is dynamic and you just want to style parts of it? It stops being fun real quick.
Introducing StyledString: One API to Style Them All
After wrestling with AnnotatedString one too many times, I decided to build something better. Not a massive library or a full styling framework. Just a simple, Compose-friendly abstraction that solves one very specific problem.
Say hello to StyledString 👋
The goal is simple: let you define which parts of a string should be styled or clickable, without worrying about indexOf, addStyle, or AnnotatedString.Builder. You write the text, tell it which words to style, and what to do when clicked.
Here’s what it looks like in action:
| // This list can be built in the ViewModel | |
| val styledStrings = persistentListOf( | |
| StyledString.ClickableEmail( | |
| highlightedText = "support@example.com", | |
| email = "support@example.com", | |
| style = SpanStyle( | |
| color = Color.Blue, | |
| textDecoration = TextDecoration.Underline | |
| ) | |
| ), | |
| StyledString.ClickableUrl( | |
| highlightedText = "website", | |
| url = "https://euryperez.dev", | |
| style = SpanStyle( | |
| color = Color.Blue, | |
| textDecoration = TextDecoration.Underline | |
| ) | |
| ) | |
| ) | |
| // In your Compose Screen | |
| StyledText( | |
| fullText = "Contact us at support@example.com or visit our website", | |
| styledStrings = styledStrings, | |
| style = MaterialTheme.typography.label, | |
| onClick = { styled -> | |
| when (styled) { | |
| is ClickableEmail -> openEmailClient(styled.email) | |
| is ClickableUrl -> openUrl(styled.url) | |
| } | |
| } | |
| ) |
That’s it. No manual text construction. No index math. No boilerplate.
Just clean, readable, declarative styling that works with real-world text.
And because StyledString supports types like Simple, ClickableEmail, and ClickableUrl, it’s easy to extend and reuse across your app. You get clickable, styled text without giving up sanity or maintainability 🙏
StyledString Under the Hood: The Engine Behind the API
Let’s pop the hood and go step-by-step through how StyledString works 🧠
It may feel like magic 🪄 when you use StyledText in your UI, but behind the scenes it’s just a clean, composable-friendly architecture built to reduce styling pain without adding unnecessary complexity.
This section covers every part of the system, from how styles are described, to how they’re found, applied, and rendered on screen.
🧱 1. The Data Model: StyledString and ClickableStyleString
At the core of this entire utility is a sealed interface called StyledString. This is how we model pieces of text that should be styled in some way.
| @Immutable | |
| sealed interface StyledString { | |
| val highlightedText: String | |
| val style: SpanStyle | |
| // Add `Simple` type | |
| // Add `ClickableEmail` type | |
| // Add `ClickableUrl` type | |
| } | |
Every StyledString needs two pieces of info:
highlightedText: the exact portion of the full text that should be styledstyle: theSpanStyle that defines how it should look (color, underline, font weight, etc.)
We then define a few specific types that implement this interface:
| @Immutable | |
| data class Simple( | |
| override val highlightedText: String, | |
| override val style: SpanStyle, | |
| ) : StyledString |
This one is purely visual, it changes how the text looks but doesn’t respond to clicks.
Then we introduce interactive types:
| @Immutable | |
| data class ClickableEmail( | |
| override val highlightedText: String, | |
| val email: String, | |
| override val style: SpanStyle, | |
| ) : StyledString, ClickableStyleString | |
| @Immutable | |
| data class ClickableUrl( | |
| override val highlightedText: String, | |
| val url: String, | |
| override val style: SpanStyle | |
| ) : StyledString, ClickableStyleString |
These do the same styling work, but also carry extra data (like the URL or email they should open when tapped). And importantly, they implement a second interface: ClickableStyleString.
| sealed interface ClickableStyleString |
That tiny interface is a big deal, it lets us distinguish between visual-only styles and ones that should respond to clicks. This keeps our click-handling logic clean and type-safe 💡
You can add more variants easily, maybe one for @mentions, #hashtags, or phone numbers, just create another data class and optionally implement ClickableStyleString.
🎯 2. Styling and Linking: applyStyle
Once we know what text should be styled, we need a way to apply those styles to the actual AnnotatedString.
That’s the job of applyStyle(), a simple extension function that applies styles (and click listeners) based on the type of StyledString.
| private fun AnnotatedString.Builder.applyStyle( | |
| styledString: StyledString, | |
| startIndex: Int, | |
| endIndex: Int, | |
| onClick: (ClickableStyleString) -> Unit | |
| ) { | |
| when (styledString) { | |
| is StyledString.ClickableUrl -> TODO() | |
| is StyledString.ClickableEmail -> TODO() | |
| is StyledString.Simple -> TODO() | |
| } | |
| } |
This function is called once for each match of each StyledString.
Now let’s look at what it does:
| is StyledString.ClickableUrl -> { | |
| val linkAnnotation = LinkAnnotation.Url( | |
| url = styledString.url, | |
| styles = TextLinkStyles(style = styledString.style), | |
| linkInteractionListener = { onClick(styledString) } | |
| ) | |
| addLink(linkAnnotation, startIndex, endIndex) | |
| } |
If it’s a URL, we create a LinkAnnotation.Url, attach the style, and give it a click listener. addLink handles attaching that to the correct range of text.
We do similar but using LinkAnnotation.Clickable for emails:
| is StyledString.ClickableEmail -> { | |
| val linkAnnotation = LinkAnnotation.Clickable( | |
| tag = styledString.highlightedText, | |
| styles = TextLinkStyles(style = styledString.style), | |
| linkInteractionListener = { onClick(styledString) } | |
| ) | |
| addLink(linkAnnotation, startIndex, endIndex) | |
| } |
And if the style is just visual (non-clickable), we apply a regular span:
| is StyledString.Simple -> { | |
| addStyle( | |
| style = styledString.style, | |
| start = startIndex, | |
| end = endIndex | |
| ) | |
| } |
This separation keeps all style-application logic in one place. If you ever want to support a new type of link or behavior, this is the only function you’d need to update.
🔍 3. Matching Text: findAllOccurrences
Before we apply styles, we need to find all the places in the text where a given highlightedText appears. That’s what this function is for.
| /** | |
| * Find all occurrences of a substring in a string, optionally ignoring case. | |
| * | |
| * @param substring The substring to search for. | |
| * @param ignoreCase Whether to perform a case-insensitive search. | |
| * @return A list of indices where the substring was found. | |
| */ | |
| private fun String.findAllOccurrences( | |
| substring: String, | |
| ignoreCase: Boolean = false | |
| ): List<Int> | |
This takes the full text and returns a list of starting indices for every match of the given substring.
Here’s how it works:
| if (substring.isEmpty()) return emptyList() |
Quick early exit for empty substrings. Avoids weird edge cases.
Then we prepare for case-insensitive searches (if needed):
| val indices = mutableListOf<Int>() | |
| val searchString = if (ignoreCase) this.lowercase() else this | |
| val searchSubstring = if (ignoreCase) substring.lowercase() else substring |
Now we iterate through the string, finding all matches:
| var startIndex = 0 | |
| val maxStartIndex = length - substring.length | |
| while (startIndex <= maxStartIndex) { | |
| val index = searchString.indexOf(searchSubstring, startIndex) | |
| if (index == -1) break | |
| indices.add(index) | |
| startIndex = index + 1 | |
| } |
We finally, return the results:
| return indices.toList() |
This keeps our styling logic flexible and resilient, whether we’re styling a word that appears once or a dozen times.
🧠 4. Building the AnnotatedString: rememberStyledAnnotationString
Here’s the function that brings it all together. It receives the full text and your StyledString list, and returns an AnnotatedString with all the styles applied.
| @Composable | |
| fun rememberStyledAnnotationString( | |
| fullText: String, | |
| styledStrings: ImmutableList<StyledString>, | |
| ignoreCase: Boolean = false, | |
| onClick: (ClickableStyleString) -> Unit | |
| ): AnnotatedString |
We make sure to use rememberUpdatedState() to keep the click listener fresh:
| val currentOnClick by rememberUpdatedState(onClick) |
Then we use remember to cache the work unless inputs change:
| return remember(fullText, styledStrings, ignoreCase) { | |
| // TODO: build annotated string | |
| } |
We start by appending the full unstyled text. Then for each StyledString, we find all matching locations and apply the style:
| buildAnnotatedString { | |
| append(fullText) | |
| styledStrings.fastForEach { styledStringInfo -> | |
| val indices = | |
| fullText.findAllOccurrences(styledStringInfo.highlightedText, ignoreCase) | |
| indices.fastForEach { startIndex -> | |
| val endIndex = startIndex + styledStringInfo.highlightedText.length | |
| applyStyle(styledStringInfo, startIndex, endIndex, currentOnClick) | |
| } | |
| } | |
| } |
This loop is what lets the styling be dynamic and multi-target. You can pass any localized or runtime-generated string as fullText, and it will still apply styles correctly.
🧩 5. The Composable: StyledText
Finally, here’s the StyledText composable that ties everything together.
| @Composable | |
| fun StyledText( | |
| fullText: String, | |
| styledStrings: ImmutableList<StyledString>, | |
| style: TextStyle, | |
| modifier: Modifier = Modifier, | |
| onClick: (ClickableStyleString) -> Unit = {}, | |
| ignoreCase: Boolean = false, | |
| ) { | |
| // TODO: Implementation | |
| } |
You pass in the full text, the styles, and optionally a click handler. Here’s what it does internally:
| val annotatedString = rememberStyledAnnotationString( | |
| fullText = fullText, | |
| styledStrings = styledStrings, | |
| ignoreCase = ignoreCase, | |
| onClick = onClick | |
| ) |
This calls the logic we just walked through. Which returns a styled AnnotatedString.
Then we render it:
| Text( | |
| modifier = modifier, | |
| text = annotatedString, | |
| style = style | |
| ) |
It’s just a regular Compose Text. But with all your style logic pre-baked. And now your UI code stays clean and declarative 🌚
⚡️ 6. StyledText in practice
Now let’s see StyledText in practice, for this I’ve put together this preview that you can test yourself:
| @PreviewLightDark | |
| @Composable | |
| private fun StyledTextPreview() { | |
| MyTheme { | |
| Box( | |
| modifier = Modifier | |
| .background(color = MaterialTheme.colors.background) | |
| .padding(16.dp) | |
| ) { | |
| // This list can be built in the ViewModel | |
| val styledStrings = persistentListOf( | |
| StyledString.ClickableEmail( | |
| highlightedText = "support@example.com", | |
| email = "support@example.com", | |
| style = SpanStyle( | |
| color = Color.Gray, | |
| textDecoration = TextDecoration.Underline | |
| ) | |
| ), | |
| StyledString.ClickableUrl( | |
| highlightedText = "website", | |
| url = "https://euryperez.dev", | |
| style = SpanStyle( | |
| color = Color.Gray, | |
| textDecoration = TextDecoration.Underline | |
| ) | |
| ) | |
| ) | |
| // In your Compose Screen | |
| StyledText( | |
| fullText = "Contact us at support@example.com or visit our website", | |
| styledStrings = styledStrings, | |
| style = MaterialTheme.typography.body2, | |
| color = MaterialTheme.colors.onBackground, | |
| onClick = { styled -> | |
| when (styled) { | |
| is StyledString.ClickableEmail -> TODO() | |
| is StyledString.ClickableUrl -> TODO() | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| } |
This is what you will see in the preview:

Job Offers
✅ Wrap-Up: A Simple Engine with Clean Output
To summarize, we’ve built a fully reusable Compose utility that:
- Describes styling declaratively with
StyledString - Separates visual and clickable styles safely
- Applies spans and links using
applyStyle - Finds multiple matches with
findAllOccurrences - Assembles everything in a compose-stable way
- Wraps up in a clean API:
StyledText
No indexOf, no brittle range logic, no need to copy and paste buildAnnotatedString boilerplate anymore.
Get the full solution here.
Final Thoughts 🎯
Jetpack Compose gives us a lot of power, but not always the most ergonomic tools out of the box. AnnotatedString is awesome for one-offs, but once your UI needs multiple styles, reused patterns, or dynamic click handling, it gets verbose fast.
That’s where StyledString comes in.
It doesn’t replace AnnotatedString, it wraps it, giving you a safer and clearer way to describe intent:
→ “Make this word bold”
→ “Make this phrase a link”
→ “Style every instance of this string”
You stop thinking about text offsets and span ranges and start thinking in terms of meaning. The result: cleaner code, less boilerplate, and a better developer experience 💆
🧩 Easy to Adopt
You don’t need to refactor your whole app to use StyledString.
Start by replacing one or two Text() elements with StyledText(). Replace your inline buildAnnotatedString { ... } blocks with a simple list of StyledString.Simple or ClickableUrl.
That’s it. You’re in ✨
🛠️ Easy to Extend
Got more use cases?
- Style
#hashtags? - Handle
@mentions? - Auto-detect phone numbers?
- Add icons or background highlights?
Just create a new data class that implements StyledString, and handle it inside applyStyle(). The rest of the system stays untouched.
This kind of separation makes your text logic modular, testable, and adaptable to future design or business needs.
If you come up with something interesting, don’t forget to share it in the comments section 😉
🫱 Your Turn
Now that you’ve seen how it works (and how little code it really takes), go ahead and try it in your next Compose screen. No more fiddly AnnotatedString.Builder code. No more duplicated span logic. Just describe what you want, and let StyledText take care of the rest.
Make text styling in Compose simple again 😎
🤝 Thanks for reading
If you end up using StyledString in your project, let me know! Always cool to see where these little patterns land in the real world 👀
Thanks for reading! If you found this helpful, consider sharing it with a fellow dev, clapping or dropping a comment. It helps a lot ✌️
This article was previously published on proandroiddev.com.


