Bluesky (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Bluesky’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 (Bluesky’s triple-theme, the bright blue accent, the like-pop, the pinned custom-feed selector, the butterfly) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,FloatingActionButtonfor compose,sp/dpinstead ofpt. Bluesky also ships on Android, 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/BlueskyColors.kt
import androidx.compose.ui.graphics.Color
object BlueskyColors {
// Light
val Canvas = Color(0xFFFFFFFF)
val Surface1 = Color(0xFFF1F3F5)
val Surface2 = Color(0xFFE2E8F0)
val Divider = Color(0xFFE2E8F0)
val Text = Color(0xFF0B0F14)
val TextSec = Color(0xFF697787)
val TextTer = Color(0xFF8B98A5)
// Dim
val DimCanvas = Color(0xFF1E2936)
val DimSurface1 = Color(0xFF27313E)
val DimDivider = Color(0xFF2E4052)
val DimTextSec = Color(0xFF9CA6B5)
// Dark
val DarkCanvas = Color(0xFF0B0F14)
val DarkSurface1 = Color(0xFF161E27)
val DarkDivider = Color(0xFF1E2936)
val DarkTextSec = Color(0xFF8B98A5)
// Brand
val Blue = Color(0xFF1185FE)
val BluePressed = Color(0xFF0F6FD6)
val Like = Color(0xFFEC4899)
val Repost = Color(0xFF2DBE85)
// Semantic
val Error = Color(0xFFE5484D)
val Warning = Color(0xFFF5A623)
}
Bluesky ships three themes. Provide a sealed selection and map each to a Material 3 scheme; never collapse Dim into Dark — they are distinct comfort levels.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
enum class BskyTheme { Light, Dim, Dark }
private val LightScheme = lightColorScheme(
primary = BlueskyColors.Blue, onPrimary = Color.White,
background = BlueskyColors.Canvas, onBackground = BlueskyColors.Text,
surface = BlueskyColors.Canvas, onSurface = BlueskyColors.Text,
surfaceVariant = BlueskyColors.Surface1, outline = BlueskyColors.Divider,
error = BlueskyColors.Error,
)
private val DimScheme = darkColorScheme(
primary = BlueskyColors.Blue, onPrimary = Color.White,
background = BlueskyColors.DimCanvas, onBackground = Color(0xFFF1F3F5),
surface = BlueskyColors.DimSurface1, onSurface = Color(0xFFF1F3F5),
surfaceVariant = BlueskyColors.DimSurface1, outline = BlueskyColors.DimDivider,
error = BlueskyColors.Error,
)
private val DarkScheme = darkColorScheme(
primary = BlueskyColors.Blue, onPrimary = Color.White,
background = BlueskyColors.DarkCanvas, onBackground = Color(0xFFF1F3F5),
surface = BlueskyColors.DarkSurface1, onSurface = Color(0xFFF1F3F5),
surfaceVariant = BlueskyColors.DarkSurface1, outline = BlueskyColors.DarkDivider,
error = BlueskyColors.Error,
)
@Composable
fun BlueskyTheme(theme: BskyTheme, content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = when (theme) {
BskyTheme.Light -> LightScheme
BskyTheme.Dim -> DimScheme
BskyTheme.Dark -> DarkScheme
},
typography = BlueskyTypography,
content = content,
)
2. Typography
Bluesky uses the system font at reading sizes; the web client renders Inter. For parity drop Inter TTFs in res/font/ (lowercase, snake_case); otherwise reference FontFamily.Default (Roboto).
// ui/theme/BlueskyType.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
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, 15sp body at 1.4)
object BlueskyText {
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 = 21.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val DisplayName = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp) // 1.4
val Handle = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val FeedTab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = (-0.1).sp)
val Count = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 16.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = (-0.1).sp)
val ButtonSec = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 17.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
val ReplyCtx = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
}
val BlueskyTypography = Typography(
headlineLarge = BlueskyText.TitleLarge,
headlineSmall = BlueskyText.Section,
titleMedium = BlueskyText.DisplayName,
bodyMedium = BlueskyText.Body,
labelSmall = BlueskyText.Tab,
)
3. Signature Components
Skeet Card (the core unit)
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.text.AnnotatedString
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun SkeetCard(
displayName: String,
handle: String, // "pfrazee.com"
timestamp: String,
body: String,
avatarUrl: String,
replyContext: String? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.background(BlueskyColors.Canvas)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
replyContext?.let {
Text("↩ $it", style = BlueskyText.ReplyCtx, color = BlueskyColors.TextSec,
modifier = Modifier.padding(bottom = 6.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AsyncImage(model = avatarUrl, contentDescription = null,
modifier = Modifier.size(42.dp).clip(CircleShape))
Column(Modifier.weight(1f)) {
Text(
buildAnnotatedString {
append(displayName); append(" ")
},
style = BlueskyText.DisplayName, color = BlueskyColors.Text, maxLines = 1,
)
Text("@$handle · $timestamp", style = BlueskyText.Handle,
color = BlueskyColors.TextSec, maxLines = 1)
}
Icon(Icons.Filled.MoreHoriz, null, tint = BlueskyColors.TextSec, modifier = Modifier.size(18.dp))
}
Text(body, style = BlueskyText.Body, color = BlueskyColors.Text,
modifier = Modifier.padding(top = 8.dp))
Row(
Modifier.fillMaxWidth().padding(top = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
ActionItem(Icons.Outlined.ChatBubbleOutline, 8, BlueskyColors.TextSec)
RepostButton(count = 14)
LikeButton(count = 142)
ActionItem(Icons.Filled.Share, 0, BlueskyColors.TextSec)
}
}
HorizontalDivider(thickness = 1.dp, color = BlueskyColors.Divider)
}
@Composable
private fun ActionItem(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 = BlueskyText.Count, color = tint)
}
}
Like Button (the pop-scale signature)
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.interaction.MutableInteractionSource
@Composable
fun LikeButton(count: Int, modifier: Modifier = Modifier) {
var liked 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) {
liked = !liked
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ light impact
scope.launch {
scale.animateTo(1.25f, spring(dampingRatio = 0.45f, stiffness = 600f))
scale.animateTo(1f, spring(dampingRatio = 0.7f))
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (liked) "Liked" else "Like",
tint = if (liked) BlueskyColors.Like else BlueskyColors.TextSec,
modifier = Modifier.size(18.dp).scale(scale.value),
)
Text("${if (liked) count + 1 else count}", style = BlueskyText.Count,
color = if (liked) BlueskyColors.Like else BlueskyColors.TextSec)
}
}
Repost Button
@Composable
fun RepostButton(count: Int, modifier: Modifier = Modifier) {
var reposted 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) {
reposted = !reposted
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch { scale.animateTo(1.1f, spring()); scale.animateTo(1f, spring()) }
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Repeat,
contentDescription = if (reposted) "Reposted" else "Repost",
tint = if (reposted) BlueskyColors.Repost else BlueskyColors.TextSec,
modifier = Modifier.size(18.dp).scale(scale.value))
Text("${if (reposted) count + 1 else count}", style = BlueskyText.Count,
color = if (reposted) BlueskyColors.Repost else BlueskyColors.TextSec)
}
}
Primary Button & Compose FAB
@Composable
fun BskyPrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = onClick,
modifier = modifier,
shape = CircleShape, // full pill
colors = ButtonDefaults.buttonColors(
containerColor = BlueskyColors.Blue, contentColor = Color.White,
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
) { Text(text, style = BlueskyText.Button) }
}
@Composable
fun ComposeFab(onClick: () -> Unit) {
val haptics = LocalHapticFeedback.current
FloatingActionButton(
onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onClick() },
containerColor = BlueskyColors.Blue,
contentColor = Color.White,
shape = CircleShape,
modifier = Modifier.size(56.dp),
) { Icon(Icons.Filled.Edit, contentDescription = "Compose", modifier = Modifier.size(24.dp)) }
}
4. Distinctive System — Pinned Custom-Feed Selector
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
@Composable
fun FeedSelector(feeds: List<String>, selected: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) {
LazyRow(
modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
items(feeds) { feed ->
val active = feed == selected
Column(
Modifier.clickable { onSelect(feed) }.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(feed, style = BlueskyText.FeedTab,
color = if (active) BlueskyColors.Blue else BlueskyColors.TextSec)
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (active) BlueskyColors.Blue else Color.Transparent),
)
}
}
}
}
Pair the switch with a 250ms Crossfade on the feed list and a small horizontal nudge (animateDpAsState).
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Bluesky’s iOS tab bar is .regularMaterial; Android has no first-class live blur, so use a 94%-opaque canvas. Active tint is Bluesky Blue.
@Composable
fun BlueskyBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = BlueskyColors.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,
"Chat" to Icons.AutoMirrored.Filled.Chat,
"Profile" to Icons.Filled.AccountCircle,
)
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 = BlueskyText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = BlueskyColors.Blue,
selectedTextColor = BlueskyColors.Blue,
unselectedIconColor = BlueskyColors.TextSec,
unselectedTextColor = BlueskyColors.TextSec,
indicatorColor = BlueskyColors.Blue.copy(alpha = 0.12f),
),
)
}
}
}
The compose FAB floats above this bar — place it in the Scaffold floatingActionButton slot with FabPosition.End.
6. Motion
| Moment | Compose recipe |
|---|---|
| Like pop | Animatable 1 → 1.25 (spring(dampingRatio = 0.45f, stiffness = 600f)) → 1, fill Like, HapticFeedbackType.TextHandleMove |
| Repost bounce | Animatable 1 → 1.1 → 1 spring(), tint Repost, HapticFeedbackType.LongPress |
| Feed switch | Crossfade(tween(250)) + small animateDpAsState horizontal nudge |
| Compose FAB | scale 0.94 on press (FAB ripple), present compose as full screen / bottom sheet |
| Theme switch | recompose with the new scheme; animateColorAsState smooths surface transitions ~200ms |
// Like pop (see LikeButton): scale.animateTo(1.25f, spring(dampingRatio = 0.45f, stiffness = 600f))
// Repost (see RepostButton): scale.animateTo(1.1f, spring())
Haptics: prefer LocalHapticFeedback. For a crisper like tap use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+).
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For the butterfly logomark, ship a vector drawable and load via ImageVector.vectorResource(R.drawable.bsky_butterfly).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Reply | bubble.left |
Icons.Outlined.ChatBubbleOutline |
| Repost | arrow.2.squarepath |
Icons.Filled.Repeat |
| Like | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Reply context | arrow.turn.up.left |
Icons.AutoMirrored.Filled.Reply |
| 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 |
| Chat (tab) | message.fill |
Icons.AutoMirrored.Filled.Chat |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Feed settings | slider.horizontal.3 |
Icons.Filled.Tune |
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 per theme (dark icons on Light, light on Dim/Dark). UseScaffoldinsets so the FAB clears the gesture nav. - Triple theme: expose Light / Dim / Dark via a persisted setting; never collapse Dim into Dark — Bluesky treats them as distinct comfort levels. Smooth surface transitions with
animateColorAsState~200ms. - Font scaling:
sphonors the user’s scale — keep it generous on skeet body and names (conversational app). Pin only tab labels. - TalkBack: merge a skeet’s name/handle/time/body with
Modifier.semantics(mergeDescendants = true); expose like/repoststateDescription(“Liked”/“Reposted”); make the feed selector a tab list and announce the active feed. - 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:
#697787on#FFFFFF,#8B98A5on#0B0F14, and#9CA6B5on#1E2936all pass WCAG AA at 14sp+. Validate 10sp tab labels and bump if targeting strict compliance. - Dynamic color: do not enable Material You — Bluesky’s brand requires the fixed bright-blue accent and its three defined canvases regardless of wallpaper.