coderef

Bluesky

Bluesky's iOS app is a clean, friendly, sky-bright timeline that feels open and uncluttered. The default light surface is pure white (#FFFFFF) with near-black text (#0B0F14), and it offers two darker variants: a soft "Dim" mode on a…
DownloadRaw
Bluesky

Build with the Bluesky look.

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

Palette

At a glance

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

Add it to your project

Then prompt your agent

Design System · Bluesky

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

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 — NavigationBar instead of a UITabBar, FloatingActionButton for compose, sp/dp instead of pt. 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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); flip system-bar icon contrast per theme (dark icons on Light, light on Dim/Dark). Use Scaffold insets 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: sp honors 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/repost stateDescription (“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: #697787 on #FFFFFF, #8B98A5 on #0B0F14, and #9CA6B5 on #1E2936 all 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.
All mobile designs →

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