coderef

Mastodon

Mastodon's iOS app is a calm, readable, content-first timeline that feels closer to a well-set magazine than to an algorithmic firehose.
DownloadRaw
Mastodon

Build with the Mastodon look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
light
Typefaces
SF Pro Text, SF Pro Display, SF Mono
Sections
8
Length
3,192 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Mastodon

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

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 — NavigationBar instead of a UITabBar, FloatingActionButton for compose, sp/dp instead of pt. 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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); flip system-bar icon contrast with the theme (light icons on #191B22, dark on #FFFFFF). Use Scaffold insets so the FAB clears the gesture nav.
  • Light + dark: provide both schemes via isSystemInDarkTheme(); never collapse the dark canvas to #000000 — keep the #191B22 blue cast that is Mastodon’s identity.
  • Font scaling: sp honors 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/favourite stateDescription (“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: #606984 on #FFFFFF and #9BAEC8 on #191B22 both 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.
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses