coderef

X (Twitter)

X's iOS app is a newsroom collapsed into a phone screen. The default canvas is pure black (#000000) — a deliberate choice that pairs OLED battery savings with the stark, reportage-style aesthetic X has cultivated since the 2023 rebrand…
DownloadRaw
X (Twitter)

Build with the X (Twitter) look.

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

Palette

At a glance

Theme
dark
Typefaces
Chirp UI, Chirp Display, Post body
Sections
8
Length
4,574 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · X (Twitter)

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

X (Twitter) (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports X’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, the per-action color micro-animations, 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 (X’s true-black OLED canvas, hairline-separated feed, single-blue structural accent, the three 1:1 action colors, the like-burst micro-interaction) while making everything idiomatic Android — NavigationBar instead of a UITabBar, HorizontalDivider hairlines instead of card surfaces, a translucent Surface instead of .regularMaterial blur, sp/dp instead of pt. The app is true-black-first with dim and light alternates; all three are wired below.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars and post media. No color extraction — X’s palette is fixed brand chrome.

1. Color Tokens

// ui/theme/XColors.kt
import androidx.compose.ui.graphics.Color

object XColors {
    // Canvas & surfaces (dark / default)
    val Canvas    = Color(0xFF000000) // true-black OLED
    val Surface1  = Color(0xFF16181C)
    val Surface2  = Color(0xFF1E2024)
    val Divider   = Color(0xFF2F3336) // the signature 1pt hairline
    val DimCanvas   = Color(0xFF15202B)
    val DimSurface1 = Color(0xFF192734)
    val DimDivider  = Color(0xFF38444D)

    // Canvas & surfaces (light)
    val LightCanvas   = Color(0xFFFFFFFF)
    val LightSurface1 = Color(0xFFF7F9F9)
    val LightSurface2 = Color(0xFFEFF3F4)

    // Text
    val TextPrimaryDark    = Color(0xFFE7E9EA) // warm off-white, not pure white
    val TextPrimaryLight   = Color(0xFF0F1419)
    val TextSecondaryDark  = Color(0xFF71767B)
    val TextSecondaryLight = Color(0xFF536471)

    // Brand / action
    val Blue        = Color(0xFF1D9BF0) // links, replies, active underline, verified
    val BluePressed = Color(0xFF1A8CD8)
    val RepostGreen = Color(0xFF00BA7C)
    val LikePink    = Color(0xFFF91880) // not red — saturated magenta-pink
    val VerifiedGold = Color(0xFFEAB308)
    val VerifiedGray = Color(0xFF829AAB)
    val ErrorRed    = Color(0xFFF4212E)
    val SpacesPurple = Color(0xFF7856FF)
}

X opens dark (true black) for new users, with dim and light alternates. Wire the dark scheme as the default; primary is the structural blue. Note the active tab tint is the stark off-white #E7E9EA, not blue — blue is reserved for links/replies/verified/the feed-filter underline.

// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme

private val XDark = darkColorScheme(
    primary        = XColors.Blue,
    onPrimary      = XColors.TextPrimaryDark,
    background     = XColors.Canvas,
    onBackground   = XColors.TextPrimaryDark,
    surface        = XColors.Surface1,
    onSurface      = XColors.TextPrimaryDark,
    surfaceVariant = XColors.Surface2,
    outline        = XColors.Divider,
    error          = XColors.ErrorRed,
)

private val XLight = lightColorScheme(
    primary        = XColors.Blue,
    onPrimary      = XColors.LightCanvas,
    background     = XColors.LightCanvas,
    onBackground   = XColors.TextPrimaryLight,
    surface        = XColors.LightSurface1,
    onSurface      = XColors.TextPrimaryLight,
    surfaceVariant = XColors.LightSurface2,
    outline        = XColors.LightSurface2,
    error          = XColors.ErrorRed,
)

@Composable
fun XTheme(
    darkTheme: Boolean = isSystemInDarkTheme(), // default-dark; expose dim as a third option in-app
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (darkTheme) XDark else XLight,
    typography = XTypography,
    content = content,
)

2. Typography

X licenses Chirp (Swiss Typefaces & Grilli Type, 2021) — a grotesque sans shipped in UI and Display optical sizes. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to Roboto (FontFamily.Default) — the closest grotesque on Android. The app switches optical size at the 17sp threshold; model it with two families.

// ui/theme/XType.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 ChirpUI = FontFamily(
    Font(R.font.chirp_regular,  FontWeight.Normal),   // 400
    Font(R.font.chirp_medium,   FontWeight.Medium),   // 500
    Font(R.font.chirp_bold,     FontWeight.Bold),     // 700
)
val ChirpDisplay = FontFamily( // more open — used at 17sp+
    Font(R.font.chirp_display_bold, FontWeight.Bold),
    Font(R.font.chirp_regular,      FontWeight.Normal),
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object XText {
    val ScreenTitle   = TextStyle(ChirpDisplay, fontWeight = FontWeight.Bold,   fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
    val SectionHeader = TextStyle(ChirpDisplay, fontWeight = FontWeight.Bold,   fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
    val DisplayName   = TextStyle(ChirpDisplay, fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 20.sp)
    val PostBody      = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
    val QuotedBody    = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
    val Handle        = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
    val ActionCount   = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
    val TrendingTopic = TextStyle(ChirpDisplay, fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 19.sp)
    val TrendingMeta  = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
    val Button        = TextStyle(ChirpUI,      fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 15.sp)
    val DMBody        = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 19.sp)
    val DMTimestamp   = TextStyle(ChirpUI,      fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 13.sp)
}

// Map onto Material 3 slots so stock components inherit the ramp
val XTypography = Typography(
    headlineLarge = XText.ScreenTitle,
    titleLarge    = XText.SectionHeader,
    titleMedium   = XText.DisplayName,
    bodyLarge     = XText.PostBody,
    bodyMedium    = XText.QuotedBody,
    labelLarge    = XText.Button,
)

3. Signature Components

Post Row (hairline-separated, column-centered feed)

The feed is a flat river of full-bleed posts joined by 1dp Dividers — never cards, never shadows. Post content begins at a 68dp leading indent (16dp margin + 32dp avatar + 12dp gutter) and the action row aligns to that same indent.

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun XPostRow(
    avatarUrl: String,
    displayName: String,
    handle: String,
    timestamp: String,
    isVerified: Boolean,
    body: String,
    replyCount: Int,
    repostCount: Int,
    likeCount: Int,
    viewCount: Int,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var isLiked by remember { mutableStateOf(false) }
    var isReposted by remember { mutableStateOf(false) }
    var isBookmarked by remember { mutableStateOf(false) }

    Column(
        modifier
            .fillMaxWidth()
            .background(XColors.Canvas)
            .clickable(onClick = onClick)
    ) {
        Row(
            Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            AsyncImage(
                model = avatarUrl,
                contentDescription = "$displayName avatar",
                modifier = Modifier.size(32.dp).clip(CircleShape),
            )
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(displayName, style = XText.DisplayName, color = XColors.TextPrimaryDark, maxLines = 1)
                    if (isVerified) {
                        Spacer(Modifier.width(2.dp))
                        Icon(Icons.Filled.Verified, contentDescription = "Verified", tint = XColors.Blue, modifier = Modifier.size(16.dp))
                    }
                    Spacer(Modifier.width(4.dp))
                    Text(
                        "@$handle · $timestamp",
                        style = XText.Handle,
                        color = XColors.TextSecondaryDark,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.weight(1f, fill = false),
                    )
                    Spacer(Modifier.weight(1f))
                    Icon(Icons.Filled.MoreHoriz, contentDescription = "More", tint = XColors.TextSecondaryDark, modifier = Modifier.size(18.dp))
                }
                Text(body, style = XText.PostBody, color = XColors.TextPrimaryDark)
                Row(
                    Modifier.fillMaxWidth().padding(top = 8.dp),
                    horizontalArrangement = Arrangement.SpaceBetween,
                ) {
                    XActionIcon(ActionType.Reply, replyCount, active = false) { /* open composer */ }
                    XActionIcon(ActionType.Repost, repostCount, active = isReposted) { isReposted = !isReposted }
                    XActionIcon(ActionType.Like, likeCount, active = isLiked) { isLiked = !isLiked }
                    XActionIcon(ActionType.Views, viewCount, active = false) {}
                    XActionIcon(ActionType.Bookmark, 0, active = isBookmarked) { isBookmarked = !isBookmarked }
                }
            }
        }
        HorizontalDivider(thickness = 1.dp, color = XColors.Divider)
    }
}

Action Icon (per-action color micro-animation — see §4)

import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.FavoriteBorder

enum class ActionType { Reply, Repost, Like, Views, Bookmark }

@Composable
fun XActionIcon(
    type: ActionType,
    count: Int,
    active: Boolean,
    onClick: () -> Unit,
) {
    val activeColor = when (type) {
        ActionType.Reply, ActionType.Bookmark -> XColors.Blue
        ActionType.Repost -> XColors.RepostGreen
        ActionType.Like -> XColors.LikePink
        ActionType.Views -> XColors.TextSecondaryDark
    }
    val tint = if (active) activeColor else XColors.TextSecondaryDark
    val icon = when (type) {
        ActionType.Reply -> Icons.Outlined.ChatBubbleOutline
        ActionType.Repost -> Icons.Filled.Repeat
        ActionType.Like -> if (active) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder
        ActionType.Views -> Icons.Filled.BarChart
        ActionType.Bookmark -> if (active) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder
    }
    Row(
        Modifier
            .sizeIn(minWidth = 44.dp, minHeight = 44.dp)
            .clickable(onClick = onClick),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Icon(icon, contentDescription = type.name, tint = tint, modifier = Modifier.size(18.75.dp))
        if (count > 0) Text(formatCount(count), style = XText.ActionCount, color = tint)
    }
}

fun formatCount(n: Int): String = when {
    n >= 1_000_000 -> "%.1fM".format(n / 1_000_000f)
    n >= 1_000 -> "%.1fK".format(n / 1_000f)
    else -> "$n"
}

Floating Post Button (inverted FAB)

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material.icons.filled.Create
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun XPostFab(onClick: () -> Unit, modifier: Modifier = Modifier, darkTheme: Boolean = true) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.95f else 1f, label = "fabScale")
    val haptics = LocalHapticFeedback.current
    // Inverted from canvas: white-on-black (dark) / black-on-white (light)
    val bg = if (darkTheme) XColors.LightCanvas else XColors.TextPrimaryLight
    val fg = if (darkTheme) XColors.Canvas else XColors.LightCanvas

    Box(
        modifier = modifier
            .padding(end = 16.dp, bottom = 16.dp)
            .size(56.dp)
            .scale(scale)
            .shadow(12.dp, CircleShape, ambientColor = Color.Black.copy(alpha = 0.4f))
            .clip(CircleShape)
            .background(bg)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(Icons.Filled.Create, contentDescription = "Compose post", tint = fg, modifier = Modifier.size(24.dp))
    }
}

Follow Pill

import androidx.compose.foundation.border

@Composable
fun XFollowPill(isFollowing: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .widthIn(min = 80.dp)
            .clip(CircleShape)
            .then(
                if (isFollowing) Modifier.border(1.dp, XColors.TextSecondaryDark, CircleShape)
                else Modifier.background(XColors.TextPrimaryDark)
            )
            .clickable(onClick = onClick)
            .padding(horizontal = 16.dp, vertical = 8.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            if (isFollowing) "Following" else "Follow", // sentence case — never uppercase
            style = XText.Button,
            color = if (isFollowing) XColors.TextPrimaryDark else XColors.Canvas,
        )
    }
}

4. Per-Action Color Micro-Animations + Column-Centered Feed

X’s most recognizable micro-interaction: each action icon scale-bounces, snaps to its role color, the count ticks up, and a .impact(.light) haptic fires. Like adds a 6-particle hexagonal burst; repost spins the glyph 360°.

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.ui.graphics.graphicsLayer
import kotlin.math.cos
import kotlin.math.sin

/** Like icon: scale 0.85 → 1.2 → 1, 6-particle hexagonal burst, count tick-up, light haptic. */
@Composable
fun XLikeButton(initialCount: Int) {
    var liked by remember { mutableStateOf(false) }
    var count by remember { mutableIntStateOf(initialCount) }
    val scale = remember { Animatable(1f) }
    val burst = remember { Animatable(0f) }
    val haptics = LocalHapticFeedback.current

    LaunchedEffect(liked) {
        if (liked) {
            haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS .impact(.light)
            scale.snapTo(0.85f)
            burst.snapTo(0f)
            kotlinx.coroutines.coroutineScope {
                launch { scale.animateTo(1.2f, spring(dampingRatio = 0.55f, stiffness = 600f)); scale.animateTo(1f) }
                launch { burst.animateTo(1f, tween(400)) }
            }
        }
    }

    Row(
        Modifier
            .sizeIn(minWidth = 44.dp, minHeight = 44.dp)
            .clickable { liked = !liked; count += if (liked) 1 else -1 },
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Box(contentAlignment = Alignment.Center) {
            // Hexagonal particle burst
            if (burst.value > 0f && burst.value < 1f) {
                repeat(6) { i ->
                    val angle = i * Math.PI / 3
                    Box(
                        Modifier
                            .size(4.dp)
                            .graphicsLayer {
                                translationX = (cos(angle) * 18 * burst.value).toFloat() * density
                                translationY = (sin(angle) * 18 * burst.value).toFloat() * density
                                alpha = 1f - burst.value
                            }
                            .clip(CircleShape)
                            .background(XColors.LikePink)
                    )
                }
            }
            Icon(
                imageVector = if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                contentDescription = "Like",
                tint = if (liked) XColors.LikePink else XColors.TextSecondaryDark,
                modifier = Modifier.size(18.75.dp).scale(scale.value),
            )
        }
        // Count tick-up: old value slides up + fades, new value slides in from below
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                (slideInVertically { it } + fadeIn()) togetherWith (slideOutVertically { -it } + fadeOut())
            },
            label = "likeCount",
        ) { c ->
            Text(formatCount(c), style = XText.ActionCount, color = if (liked) XColors.LikePink else XColors.TextSecondaryDark)
        }
    }
}

/** Repost icon spins 0 → 360° as it fills green. */
@Composable
fun XRepostButton(initialCount: Int) {
    var reposted by remember { mutableStateOf(false) }
    val rotation = remember { Animatable(0f) }
    val haptics = LocalHapticFeedback.current
    LaunchedEffect(reposted) {
        if (reposted) {
            haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
            rotation.animateTo(rotation.value + 360f, tween(400))
        }
    }
    Icon(
        Icons.Filled.Repeat,
        contentDescription = "Repost",
        tint = if (reposted) XColors.RepostGreen else XColors.TextSecondaryDark,
        modifier = Modifier
            .size(18.75.dp)
            .graphicsLayer { rotationZ = rotation.value }
            .clickable { reposted = !reposted },
    )
}

The column-centered feed is a single LazyColumn of XPostRows separated only by their built-in HorizontalDivider — set verticalArrangement = Arrangement.spacedBy(0.dp) (posts touch; the hairline is the only separator). On wide breakpoints (> 840.dp), constrain the feed column to a max width and center it, mirroring iPad split view.

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar, icon-only with no labels — X’s signature pattern. The iOS bar is .regularMaterial blur at ~85% over canvas; Android has no first-class live blur, so use an 85%-opaque canvas surface. Active tint is the stark off-white #E7E9EA (not blue); active icons are filled, inactive outlined.

import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.HomeOutlined
import androidx.compose.material.icons.outlined.MailOutline
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PeopleOutline
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults

@Composable
fun XBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = XColors.Canvas.copy(alpha = 0.85f),
        tonalElevation = 0.dp,
    ) {
        data class Tab(val filled: androidx.compose.ui.graphics.vector.ImageVector, val outlined: androidx.compose.ui.graphics.vector.ImageVector, val cd: String)
        val tabs = listOf(
            Tab(Icons.Filled.Home, Icons.Outlined.HomeOutlined, "Home"),
            Tab(Icons.Filled.Search, Icons.Filled.Search, "Search"),
            Tab(Icons.Filled.People, Icons.Outlined.PeopleOutline, "Communities"),
            Tab(Icons.Filled.Notifications, Icons.Outlined.NotificationsNone, "Notifications"),
            Tab(Icons.Filled.Mail, Icons.Outlined.MailOutline, "Messages"),
        )
        tabs.forEachIndexed { i, tab ->
            val isSel = selected == i
            NavigationBarItem(
                selected = isSel,
                onClick = { onSelect(i) },
                icon = {
                    Icon(
                        if (isSel) tab.filled else tab.outlined,
                        contentDescription = tab.cd,
                        modifier = Modifier.size(26.dp),
                    )
                },
                label = null, // icon-only — X's signature
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = XColors.TextPrimaryDark, // off-white, NOT blue
                    unselectedIconColor = XColors.TextSecondaryDark,
                    indicatorColor      = Color.Transparent, // no Material pill — X has none
                ),
            )
        }
    }
}

The feed filter (“For you” / “Following”) sits below the top nav: two Chirp 15sp/700 labels; the active one gets a 40dp × 4dp #1D9BF0 pill underline that slides between positions via an animateDpAsState offset (the Compose analog to matchedGeometryEffect).

6. Motion

Moment Compose recipe
Like tap Animatable scale 0.85 → 1.2 → 1 spring(dampingRatio = 0.55f); 6-particle graphicsLayer burst over 400ms; AnimatedContent count tick-up; HapticFeedbackType.TextHandleMove
Repost tap Animatable rotationZ += 360 over 400ms tween as tint → green; light haptic
Reply tap icon scale 0.9 → 1.05 → 1 spring; navigate to compose (slideInVertically)
Feed swipe For You ↔ Following HorizontalPager 280ms spring; underline animateDpAsState offset (≈ matchedGeometryEffect)
Pull-to-refresh custom: a line that expands into the X glyph as overscroll grows, then spins
Tab switch instant Crossfade (no slide)
Compose open ModalBottomSheet / full-screen route slideInVertically 320ms spring + scrim fade
// Feed filter underline — Android's analog to iOS .matchedGeometryEffect
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.offset

@Composable
fun XFeedFilter(selected: Int, onSelect: (Int) -> Unit) {
    val labels = listOf("For you", "Following")
    BoxWithConstraints(Modifier.fillMaxWidth().height(48.dp)) {
        val tabWidth = maxWidth / 2
        val indicatorX by animateDpAsState(
            targetValue = tabWidth * selected + (tabWidth - 40.dp) / 2,
            animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f),
            label = "filterUnderline",
        )
        Row(Modifier.fillMaxSize()) {
            labels.forEachIndexed { i, label ->
                Box(
                    Modifier.weight(1f).fillMaxHeight().clickable { onSelect(i) },
                    contentAlignment = Alignment.Center,
                ) {
                    Text(
                        label,
                        style = XText.Button,
                        color = if (selected == i) XColors.TextPrimaryDark else XColors.TextSecondaryDark,
                    )
                }
            }
        }
        Box(
            Modifier
                .offset(x = indicatorX)
                .align(Alignment.BottomStart)
                .width(40.dp)
                .height(4.dp)
                .clip(CircleShape)
                .background(XColors.Blue)
        )
        HorizontalDivider(Modifier.align(Alignment.BottomCenter), thickness = 1.dp, color = XColors.Divider)
    }
}

Haptics: prefer LocalHapticFeedback. HapticFeedbackType.TextHandleMove is the lightest stock effect ≈ iOS .impact(.light) for every action tap. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, 60).

7. Icons

X ships custom monochrome glyphs (the X logo most notably); the closest first-party set is androidx.compose.material:material-icons-extended. The X logotype, the precise Chirp-styled action glyphs, and the Grok sparkle should ship as vector drawables loaded via ImageVector.vectorResource(R.drawable.…) — the X logo is monochrome, never tinted.

Purpose SF Symbol (iOS) Material Icon (Compose)
Reply bubble.left Icons.Outlined.ChatBubbleOutline
Repost arrow.2.squarepath Icons.Filled.Repeat
Like (outline / fill) heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Views chart.bar Icons.Filled.BarChart
Bookmark (outline / fill) bookmark / bookmark.fill Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark
Share square.and.arrow.up Icons.Filled.IosShare
Verified checkmark.seal.fill Icons.Filled.Verified
Overflow ellipsis Icons.Filled.MoreHoriz
Compose (FAB) pencil.and.outline Icons.Filled.Create
Home tab house.fill Icons.Filled.Home / Icons.Outlined.HomeOutlined
Search tab magnifyingglass Icons.Filled.Search
Communities tab person.3.fill Icons.Filled.People / Icons.Outlined.PeopleOutline
Notifications tab bell.fill Icons.Filled.Notifications / Icons.Outlined.NotificationsNone
Messages tab envelope.fill Icons.Filled.Mail / Icons.Outlined.MailOutline
Grok sparkles Icons.Filled.AutoAwesome (or vector drawable)
X logo (custom glyph) vector drawable — monochrome, never tinted

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; AnimatedContent, graphicsLayer bursts, and HorizontalPager are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity with light-content system bars (the canvas is true black). The FAB floats 16.dp above the bottom bar; apply Scaffold insets so both clear the gesture nav, and let the top nav draw under the status bar with its translucent surface.
  • Font scaling: sp honors the user’s font scale — keep it on post body, quoted body, and DM body. Pin display name, handle, timestamp, and the 13sp action counts (layout-critical for the post-row header and action row) by deriving them from a fixed-fontScale LocalDensity.
  • TalkBack: merge each post into a single node with Modifier.semantics(mergeDescendants = true) and a readable label; expose action icons as separate buttons with state-aware descriptions ("Like post, 1.2 thousand likes" / "Liked"). The avatar and overflow are independent buttons.
  • Touch targets: Material guidance is 48.dp minimum. Action glyphs render at 18.75.dp — each is already wrapped in a sizeIn(minWidth = 44.dp, minHeight = 44.dp) row; bump to 48.dp if targeting strict compliance. The 32dp avatar and 20dp overflow need a 44–48dp hit area via padding.
  • Reduce Motion: respect the system “remove animations” setting — skip the like-burst particles and the repost spin, but keep the color change, the count update, and the haptic so the action still confirms.
  • Contrast: off-white #E7E9EA on #000000 exceeds WCAG AA. #71767B secondary on true black is borderline at small sizes — keep handles/timestamps at 15sp and action counts at 13sp minimum; do not dim secondary text further. Like-pink #F91880 and repost-green #00BA7C on black pass AA for the icon/count sizes used.
  • Dynamic color: do not enable Material You dynamicColorScheme() — X’s identity is the fixed true-black canvas, the structural blue, and the three 1:1 action colors regardless of wallpaper. Keep the hand-built dark/light schemes (and offer dim as an explicit in-app third theme).
All mobile designs →

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