Mastodon (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Mastodon’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Mastodon’s calm reading timeline, purple accent, the boost-spin, content-warning spoilers, federated handles) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,FloatingActionButtonfor compose,sp/dpinstead ofpt. Mastodon is also a first-class Android app, so this maps cleanly.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/MastodonColors.kt
import androidx.compose.ui.graphics.Color
object MastodonColors {
// Light
val Canvas = Color(0xFFFFFFFF)
val Surface2 = Color(0xFFF2F3F8)
val Divider = Color(0xFFC0CDD9)
val Text = Color(0xFF000000)
val TextSec = Color(0xFF606984)
val TextTer = Color(0xFF8B95A8)
// Dark — blue-gray, NOT true black
val DarkCanvas = Color(0xFF191B22)
val DarkSurface1 = Color(0xFF282C37)
val DarkSurface2 = Color(0xFF313543)
val DarkDivider = Color(0xFF393F4F)
val DarkTextSec = Color(0xFF9BAEC8)
// Brand
val Purple = Color(0xFF6364FF)
val PurplePressed = Color(0xFF563ACC)
val BoostGreen = Color(0xFF2DCE89)
// Semantic
val FavGold = Color(0xFFCA8F04)
val Error = Color(0xFFDF405A)
}
Wire both schemes into Material 3. Mastodon ships light and dark; provide both, but keep the dark canvas blue-gray (#191B22), never #000000.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val LightScheme = lightColorScheme(
primary = MastodonColors.Purple,
onPrimary = Color(0xFFFFFFFF),
background = MastodonColors.Canvas,
onBackground = MastodonColors.Text,
surface = MastodonColors.Canvas,
onSurface = MastodonColors.Text,
surfaceVariant = MastodonColors.Surface2,
outline = MastodonColors.Divider,
error = MastodonColors.Error,
)
private val DarkScheme = darkColorScheme(
primary = MastodonColors.Purple,
onPrimary = Color(0xFFFFFFFF),
background = MastodonColors.DarkCanvas, // #191B22, not black
onBackground = Color(0xFFFFFFFF),
surface = MastodonColors.DarkSurface1,
onSurface = Color(0xFFFFFFFF),
surfaceVariant = MastodonColors.DarkSurface2,
outline = MastodonColors.DarkDivider,
error = MastodonColors.Error,
)
@Composable
fun MastodonTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkScheme else LightScheme,
typography = MastodonTypography,
content = content,
)
2. Typography
Mastodon uses the system font at reading sizes; the web client renders Inter. For web parity drop Inter TTFs in res/font/ (lowercase, snake_case); otherwise reference FontFamily.Default (Roboto) — both read well.
// ui/theme/MastodonType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Optional Inter parity; fall back to FontFamily.Default if you don't bundle it
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, 16sp body at 1.45)
object MastodonText {
val TitleLarge = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 27.sp, letterSpacing = (-0.3).sp)
val DisplayName = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 23.sp) // 1.45
val BodySettings = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Handle = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val CW = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Count = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 16.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val ShowMore = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
}
val MastodonTypography = Typography(
headlineLarge = MastodonText.TitleLarge,
headlineSmall = MastodonText.Section,
titleMedium = MastodonText.DisplayName,
bodyMedium = MastodonText.Body,
labelSmall = MastodonText.Tab,
)
3. Signature Components
Toot Card (the core unit)
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun TootCard(
displayName: String,
handle: String, // "@Gargron@mastodon.social"
timestamp: String,
body: String,
avatarUrl: String,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.background(MastodonColors.Canvas)
.padding(16.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AsyncImage(
model = avatarUrl,
contentDescription = null,
modifier = Modifier.size(46.dp).clip(RoundedCornerShape(8.dp)), // rounded-square, not circle
)
Column(Modifier.weight(1f)) {
Text(displayName, style = MastodonText.DisplayName, color = MastodonColors.Text)
Text(handle, style = MastodonText.Handle, color = MastodonColors.TextSec)
}
Text(timestamp, style = MastodonText.Count, color = MastodonColors.TextSec)
Icon(Icons.Filled.MoreHoriz, null, tint = MastodonColors.TextSec, modifier = Modifier.size(18.dp))
}
Text(body, style = MastodonText.Body, color = MastodonColors.Text,
modifier = Modifier.padding(top = 10.dp))
Row(
Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
ActionIcon(Icons.AutoMirrored.Filled.Reply, 12, MastodonColors.TextSec)
BoostButton(count = 34)
FavouriteButton(count = 211)
ActionIcon(Icons.Filled.Share, 0, MastodonColors.TextSec)
}
}
HorizontalDivider(thickness = 1.dp, color = MastodonColors.Divider)
}
@Composable
private fun ActionIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, count: Int, tint: Color) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)) {
Icon(icon, null, tint = tint, modifier = Modifier.size(18.dp))
if (count > 0) Text("$count", style = MastodonText.Count, color = tint)
}
}
Boost Button (the 360° spin)
import androidx.compose.animation.core.*
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun BoostButton(count: Int, modifier: Modifier = Modifier) {
var boosted by remember { mutableStateOf(false) }
val angle = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Row(
modifier
.defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
boosted = !boosted
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ success
scope.launch {
angle.animateTo(
angle.value + if (boosted) 360f else -360f,
tween(400, easing = EaseOut),
)
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
Icons.Filled.Repeat,
contentDescription = if (boosted) "Boosted" else "Boost",
tint = if (boosted) MastodonColors.BoostGreen else MastodonColors.TextSec,
modifier = Modifier.size(18.dp).rotate(angle.value),
)
Text(
"${if (boosted) count + 1 else count}",
style = MastodonText.Count,
color = if (boosted) MastodonColors.BoostGreen else MastodonColors.TextSec,
)
}
}
Favourite Button (gold bounce)
@Composable
fun FavouriteButton(count: Int, modifier: Modifier = Modifier) {
var fav by remember { mutableStateOf(false) }
val scale = remember { Animatable(1f) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Row(
modifier
.defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)
.clickable(remember { MutableInteractionSource() }, indication = null) {
fav = !fav
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
scope.launch {
scale.animateTo(1.2f, spring(dampingRatio = 0.5f)); scale.animateTo(1f, spring())
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
if (fav) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = if (fav) "Favourited" else "Favourite",
tint = if (fav) MastodonColors.FavGold else MastodonColors.TextSec,
modifier = Modifier.size(18.dp).scale(scale.value),
)
Text("${if (fav) count + 1 else count}", style = MastodonText.Count,
color = if (fav) MastodonColors.FavGold else MastodonColors.TextSec)
}
}
Content-Warning Spoiler Card (the signature)
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
@Composable
fun ContentWarningCard(warning: String, body: String, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
Column(modifier) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MastodonColors.Surface2)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.VisibilityOff, null, tint = MastodonColors.Text, modifier = Modifier.size(16.dp))
Text(warning, style = MastodonText.CW, color = MastodonColors.Text,
modifier = Modifier.padding(start = 8.dp).weight(1f))
Text(
if (expanded) "SHOW LESS" else "SHOW MORE",
style = MastodonText.ShowMore,
color = MastodonColors.Purple,
modifier = Modifier.clickable { expanded = !expanded },
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(250)) + expandVertically(tween(250)),
exit = fadeOut(tween(250)) + shrinkVertically(tween(250)),
) {
Text(body, style = MastodonText.Body, color = MastodonColors.Text,
modifier = Modifier.padding(top = 10.dp))
}
}
}
Primary Button & Compose FAB
@Composable
fun MastoPrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MastodonColors.Purple, contentColor = Color.White,
),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp),
) { Text(text, style = MastodonText.Button) }
}
@Composable
fun ComposeFab(onClick: () -> Unit) {
val haptics = LocalHapticFeedback.current
FloatingActionButton(
onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onClick() },
containerColor = MastodonColors.Purple,
contentColor = Color.White,
shape = androidx.compose.foundation.shape.CircleShape,
modifier = Modifier.size(56.dp),
) { Icon(Icons.Filled.Edit, contentDescription = "Compose", modifier = Modifier.size(24.dp)) }
}
4. Distinctive System — Federated Timeline Switcher
@Composable
fun TimelineSwitcher(scope: String, onChange: (String) -> Unit, modifier: Modifier = Modifier) {
val scopes = listOf("Home", "Local", "Federated")
Row(modifier.fillMaxWidth()) {
scopes.forEach { s ->
val active = s == scope
Column(
Modifier.weight(1f).clickable { onChange(s) }.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
s,
style = MastodonText.DisplayName.copy(
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
),
color = if (active) MastodonColors.Purple else MastodonColors.TextSec,
)
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(if (active) MastodonColors.Purple else Color.Transparent),
)
}
}
}
}
Pair the switch with a 250ms Crossfade on the feed and an 8.dp horizontal nudge (animateDpAsState).
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Mastodon’s iOS tab bar is .regularMaterial; Android has no first-class live blur, so use a 94%-opaque canvas. Active tint is Mastodon Purple.
@Composable
fun MastodonBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = MastodonColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Notifications" to Icons.Filled.Notifications,
"Profile" to Icons.Filled.AccountBox,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(26.dp)) },
label = { Text(label, style = MastodonText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MastodonColors.Purple,
selectedTextColor = MastodonColors.Purple,
unselectedIconColor = MastodonColors.TextSec,
unselectedTextColor = MastodonColors.TextSec,
indicatorColor = MastodonColors.Purple.copy(alpha = 0.12f),
),
)
}
}
}
The persistent compose FAB floats above this bar — place it in the Scaffold floatingActionButton slot with FabPosition.End.
6. Motion
| Moment | Compose recipe |
|---|---|
| Boost spin | Animatable +360f / -360f over tween(400, EaseOut), color → BoostGreen, HapticFeedbackType.LongPress |
| Favourite bounce | Animatable 1 → 1.2 (spring(dampingRatio = 0.5f)) → 1, gold tint, light haptic |
| CW expand/collapse | AnimatedVisibility fadeIn + expandVertically over tween(250), symmetric exit |
| Timeline switch | Crossfade(tween(250)) + 8.dp animateDpAsState nudge |
| Compose FAB | scale 0.94 on press (FloatingActionButton ripple), present compose as a full screen / bottom sheet |
// Boost spin (see BoostButton): angle.animateTo(angle.value + 360f, tween(400, EaseOut))
// Favourite (see FavouriteButton): scale.animateTo(1.2f, spring(dampingRatio = 0.5f))
Haptics: prefer LocalHapticFeedback. For a richer “success” on boost use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONFIRM) (API 30+).
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Reply | arrowshape.turn.up.left |
Icons.AutoMirrored.Filled.Reply |
| Boost | arrow.2.squarepath |
Icons.Filled.Repeat |
| Favourite | star / star.fill |
Icons.Outlined.StarBorder / Icons.Filled.Star |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Content warning | eye.slash |
Icons.Filled.VisibilityOff |
| Compose (FAB) | square.and.pencil |
Icons.Filled.Edit |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Notifications (tab) | bell.fill |
Icons.Filled.Notifications |
| Profile (tab) | person.crop.square |
Icons.Filled.AccountBox |
| Visibility public | globe |
Icons.Filled.Public |
| Visibility followers | lock.fill |
Icons.Filled.Lock |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); flip system-bar icon contrast with the theme (light icons on#191B22, dark on#FFFFFF). UseScaffoldinsets so the FAB clears the gesture nav. - Light + dark: provide both schemes via
isSystemInDarkTheme(); never collapse the dark canvas to#000000— keep the#191B22blue cast that is Mastodon’s identity. - Font scaling:
sphonors the user’s scale — keep it generous on toot body and names (reading app). Pin only tab labels and “SHOW MORE”. - TalkBack: merge a toot’s name/handle/time/body with
Modifier.semantics(mergeDescendants = true); expose boost/favouritestateDescription(“Boosted”/“Favourited”); make the CW card a disclosure with a “Show content warning” action and announce the revealed body. - Touch targets: Material guidance is 48.dp minimum. Action icons are 18.dp glyphs — give each a 44–48.dp hit area via
defaultMinSize(shown above). - Contrast:
#606984on#FFFFFFand#9BAEC8on#191B22both pass WCAG AA at 14sp+. Validate 10sp tab labels and bump if targeting strict compliance. - Dynamic color: do not enable Material You — Mastodon’s brand requires the fixed purple accent and the blue-gray dark canvas regardless of wallpaper.