Substack (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Substack’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the post reader + Subscribe card, 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 (Substack’s clean white reading page, serif prose, the one rationed orange Subscribe, inbox-as-home) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ModalBottomSheetinstead of a SwiftUI sheet,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for publication art and avatars. No color extraction — Substack’s palette is a fixed neutral + one orange, so Palette is not needed. Substack is light-mode-first (a magazine page); a full dark scheme is provided.
1. Color Tokens
// ui/theme/SubstackColors.kt
import androidx.compose.ui.graphics.Color
object SubstackColors {
// Brand & conversion — the ONE accent
val Orange = Color(0xFFFF6719) // Subscribe / active / unread / like — light AND dark
val OrangePressed = Color(0xFFE5560E)
val OrangeTint = Color(0xFFFFF7F2)
val OrangeTintBorder = Color(0xFFFFD9C2)
val OrangeTintDark = Color(0xFF2A1C12)
val OrangeTintBorderDark = Color(0xFF5C3A24)
val Link = Color(0xFF4A6FE3) // inline links only
// Reading surface (light — "Reader")
val Paper = Color(0xFFFFFFFF)
val ReadingInk = Color(0xFF1F1F1F) // near-black, NOT pure black
val TitleInk = Color(0xFF1A1A1A)
val SubheadInk = Color(0xFF57534E)
val MetaGrey = Color(0xFF8A8A8A)
val Hairline = Color(0xFFECECEC)
// App surfaces (light)
val SurfaceSubtle = Color(0xFFF7F6F4)
val Divider = Color(0xFFE7E5E1)
// Dark
val DarkCanvas = Color(0xFF121212) // soft charcoal, NOT pure black
val DarkSurface1 = Color(0xFF1B1B1B)
val DarkSurface2 = Color(0xFF262626)
val DarkDivider = Color(0xFF2E2E2E)
// Text
val TextPrimary = Color(0xFF1A1A1A)
val TextSecondary = Color(0xFF6B6B6B)
val DarkTextPrimary = Color(0xFFEDEDED)
val DarkTextSecondary = Color(0xFFA6A6A6)
// Semantic
val Error = Color(0xFFD93025)
val Success = Color(0xFF1A8917)
}
Wire it into both schemes. Substack is light-first (a magazine page); the dark scheme uses soft charcoal #121212, never true black. The orange is constant.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val SubstackLight = lightColorScheme(
primary = SubstackColors.Orange,
onPrimary = Color(0xFFFFFFFF),
background = SubstackColors.Paper,
onBackground = SubstackColors.ReadingInk,
surface = SubstackColors.Paper,
onSurface = SubstackColors.TextPrimary,
surfaceVariant = SubstackColors.SurfaceSubtle,
outline = SubstackColors.Divider,
error = SubstackColors.Error,
)
private val SubstackDark = darkColorScheme(
primary = SubstackColors.Orange, // accent unchanged across modes
onPrimary = Color(0xFFFFFFFF),
background = SubstackColors.DarkCanvas,
onBackground = SubstackColors.DarkTextPrimary,
surface = SubstackColors.DarkSurface1,
onSurface = SubstackColors.DarkTextPrimary,
surfaceVariant = SubstackColors.DarkSurface2,
outline = SubstackColors.DarkDivider,
error = SubstackColors.Error,
)
@Composable
fun SubstackTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) SubstackDark else SubstackLight,
typography = SubstackTypography,
content = content,
)
2. Typography (Material 3)
Substack sets everything-you-read in a reading serif (Source Serif 4; Spectral is an equal alternative) and everything-you-operate in Inter. Drop the TTFs in res/font/. Body 18sp at 1.65 line-height for long reads.
// ui/theme/SubstackType.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val SourceSerif = FontFamily(
Font(R.font.sourceserif4_regular, FontWeight.Normal),
Font(R.font.sourceserif4_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.sourceserif4_semibold, FontWeight.SemiBold),
Font(R.font.sourceserif4_semibolditalic, FontWeight.SemiBold, FontStyle.Italic),
Font(R.font.sourceserif4_bold, FontWeight.Bold),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
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)
object SubstackText {
val ScreenTitle = TextStyle(SourceSerif, fontWeight = FontWeight.Bold, fontSize = 30.sp, lineHeight = 36.sp, letterSpacing = (-0.4).sp)
val PostTitle = TextStyle(SourceSerif, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
val SectionHead = TextStyle(SourceSerif, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 29.sp)
val PullQuote = TextStyle(SourceSerif, fontWeight = FontWeight.SemiBold, fontStyle = FontStyle.Italic, fontSize = 21.sp, lineHeight = 29.sp)
val Body = TextStyle(SourceSerif, fontWeight = FontWeight.Normal, fontSize = 18.sp, lineHeight = 30.sp) // ~1.65
val Dek = TextStyle(SourceSerif, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic, fontSize = 17.sp, lineHeight = 24.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val Label = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 18.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Eyebrow = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
}
// Map onto Material 3 slots so stock components inherit the brand voice
val SubstackTypography = Typography(
headlineLarge = SubstackText.ScreenTitle,
headlineMedium = SubstackText.PostTitle,
titleMedium = SubstackText.SectionHead,
bodyMedium = SubstackText.Body,
labelSmall = SubstackText.Tab,
)
3. Signature Components
Post Reader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
sealed interface PostBlock { data class Para(val t: String) : PostBlock; data class Pull(val t: String) : PostBlock }
@Composable
fun PostReader(
pubInitials: String, pubName: String, subscribers: String,
title: String, dek: String, byline: String,
blocks: List<PostBlock>, readingSize: Int = 18, dark: Boolean = false,
) {
val ink = if (dark) SubstackColors.DarkTextPrimary else SubstackColors.TitleInk
val bodyInk = if (dark) SubstackColors.DarkTextPrimary else SubstackColors.ReadingInk
Column(
Modifier
.fillMaxSize()
.background(if (dark) SubstackColors.DarkCanvas else SubstackColors.Paper)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier.size(34.dp).clip(RoundedCornerShape(6.dp))
.background(Brush.linearGradient(listOf(SubstackColors.Orange, SubstackColors.OrangePressed))),
contentAlignment = Alignment.Center,
) { Text(pubInitials, style = SubstackText.Label, color = Color.White) }
Column {
Text(pubName, style = SubstackText.Label, color = ink)
Text(subscribers, style = SubstackText.Caption, color = SubstackColors.MetaGrey)
}
}
Spacer(Modifier.height(18.dp))
Text(title, style = SubstackText.PostTitle, color = ink)
Spacer(Modifier.height(8.dp))
Text(dek, style = SubstackText.Dek, color = SubstackColors.SubheadInk)
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.size(26.dp).clip(RoundedCornerShape(50)).background(SubstackColors.Hairline))
Text(byline, style = SubstackText.Meta, color = SubstackColors.MetaGrey)
}
Spacer(Modifier.height(16.dp))
Divider(color = if (dark) SubstackColors.DarkDivider else SubstackColors.Hairline)
Spacer(Modifier.height(18.dp))
blocks.forEach { b ->
when (b) {
is PostBlock.Para -> {
Text(
b.t,
style = SubstackText.Body.copy(
fontSize = readingSize.sp,
lineHeight = (readingSize * 1.65f).sp,
color = bodyInk,
),
)
Spacer(Modifier.height(18.dp))
}
is PostBlock.Pull -> {
Row(Modifier.padding(bottom = 14.dp)) {
Box(Modifier.width(3.dp).fillMaxHeight().background(SubstackColors.Orange))
Text(b.t, style = SubstackText.PullQuote, color = ink,
modifier = Modifier.padding(start = 18.dp, top = 6.dp, bottom = 6.dp))
}
}
}
}
}
}
Subscribe Card / Paywall Break
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.border
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
@Composable
fun SubscribeCard(dark: Boolean = false) {
var subscribed by remember { mutableStateOf(false) }
var pressed by remember { mutableStateOf(false) }
val s by animateFloatAsState(if (pressed) 0.97f else 1f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "subBtn")
val haptics = LocalHapticFeedback.current
Column(
Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (dark) SubstackColors.OrangeTintDark else SubstackColors.OrangeTint)
.border(1.dp, if (dark) SubstackColors.OrangeTintBorderDark else SubstackColors.OrangeTintBorder,
RoundedCornerShape(10.dp))
.padding(14.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Keep reading with a subscription",
style = SubstackText.PostTitle.copy(fontSize = 15.sp),
color = if (dark) SubstackColors.DarkTextPrimary else SubstackColors.TitleInk,
textAlign = TextAlign.Center)
Text("Get every post, the full archive, and the private community.",
style = SubstackText.Caption, color = SubstackColors.MetaGrey,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp))
Button(
onClick = {
pressed = true; subscribed = true
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.buttonColors(containerColor = SubstackColors.Orange),
modifier = Modifier.fillMaxWidth().height(42.dp).scale(s),
) { Text(if (subscribed) "Subscribed ✓" else "Subscribe", style = SubstackText.Button, color = Color.White) }
Text("$8/month · $80/year · cancel anytime",
style = SubstackText.Caption.copy(fontSize = 11.sp), color = SubstackColors.MetaGrey,
modifier = Modifier.padding(top = 8.dp))
}
}
Pull-Quote
@Composable
fun PullQuote(text: String) {
Row {
Box(Modifier.width(3.dp).fillMaxHeight().background(SubstackColors.Orange))
Text(text, style = SubstackText.PullQuote, color = SubstackColors.TitleInk,
modifier = Modifier.padding(start = 18.dp, top = 6.dp, bottom = 6.dp))
}
}
Inbox Row
import androidx.compose.foundation.clickable
@Composable
fun InboxRow(pubName: String, title: String, meta: String, unread: Boolean, dark: Boolean = false, onClick: () -> Unit = {}) {
Row(
Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(56.dp).clip(RoundedCornerShape(6.dp))
.background(Brush.linearGradient(listOf(SubstackColors.Orange, SubstackColors.OrangePressed)))
)
Column(Modifier.weight(1f)) {
Text(pubName.uppercase(), style = SubstackText.Eyebrow, color = SubstackColors.Orange)
Text(title, style = SubstackText.PostTitle.copy(fontSize = 15.sp, lineHeight = 20.sp),
color = if (dark) SubstackColors.DarkTextPrimary else SubstackColors.TextPrimary,
maxLines = 2, modifier = Modifier.padding(vertical = 3.dp))
Text(meta, style = SubstackText.Caption,
color = if (dark) SubstackColors.DarkTextSecondary else SubstackColors.TextSecondary)
}
if (unread) Box(Modifier.size(8.dp).clip(RoundedCornerShape(50)).background(SubstackColors.Orange))
}
Divider(color = if (dark) SubstackColors.DarkDivider else SubstackColors.Divider)
}
4. Navigation
Substack has a 4-tab bottom strip (Inbox is home) and a serif large title. On Android, model the strip as a NavigationBar (no tint pill — active is the one orange) and the post screen with a slim back/share TopAppBar.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@Composable
fun SubstackBottomBar(selected: Int, onSelect: (Int) -> Unit, dark: Boolean = false) {
NavigationBar(
containerColor = if (dark) SubstackColors.DarkCanvas else SubstackColors.Paper,
tonalElevation = 0.dp,
) {
val items = listOf(
"Inbox" to Icons.Filled.Email,
"Discover" to Icons.Filled.Search,
"Notes" to Icons.AutoMirrored.Filled.Notes,
"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(22.dp)) },
label = { Text(label, style = SubstackText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = SubstackColors.Orange,
selectedTextColor = SubstackColors.Orange,
unselectedIconColor = if (dark) SubstackColors.DarkTextSecondary else Color(0xFF9A9A9A),
unselectedTextColor = if (dark) SubstackColors.DarkTextSecondary else Color(0xFF9A9A9A),
indicatorColor = Color.Transparent, // no Material pill — Substack has none
),
)
}
}
}
The comment composer and share sheet are ModalBottomSheets (16.dp top radius). The comment input is Inter, not the serif — comments are conversation, not the article. Show an orange unread-dot badge on the Inbox NavigationBarItem when new posts arrive.
5. Motion
Substack motion is quiet and editorial — the reading column barely moves.
| Moment | Compose recipe |
|---|---|
| Subscribe success | button animateFloatAsState scale 1→0.97→1 spring(MediumBouncy); label Crossfade to “Subscribed ✓”; HapticFeedbackType.LongPress |
| Like (heart) | icon animateFloatAsState 1→1.3→1 spring(dampingRatio=0.4f); soft haptic; count increments |
| Paywall reveal | locked section Modifier.blur(animateDpAsState(if locked 6.dp else 0.dp)); SubscribeCard AnimatedVisibility(slideInVertically + fadeIn, tween(250)) |
| Inbox → post | shared-element via Modifier.sharedElement (Compose 1.7) or scale tween(300) on the thumbnail |
| Read-progress line | Canvas/Box width = scrollState.value / maxValue, 2.dp, Orange, top edge |
| Pull to refresh | PullToRefreshContainer with an orange indicator |
| Tab switch | instant; selected glyph Crossfade(tween(120)); unread dot scales in |
// Like heart pop — the canonical Substack micro-interaction
val pop by animateFloatAsState(
targetValue = if (liked) 1.3f else 1f,
animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium),
label = "likePop",
)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft confirm on Subscribe success and the Like pop. Restack shows a non-modal “Restacked to Notes” Snackbar. The read-progress line is ambient — no haptic.
6. Icons
Substack’s chrome iconography is conventional; the closest first-party set is androidx.compose.material:material-icons-extended. Publication tiles fall back to an orange gradient when no art is set; the unread dot is a colored circle.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Inbox (tab) | tray / tray.full |
Icons.Filled.Email |
| Discover (tab) | magnifyingglass |
Icons.Filled.Search |
| Notes (tab) | text.alignleft |
Icons.AutoMirrored.Filled.Notes |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Overflow | ellipsis |
Icons.Filled.MoreVert |
| Like | heart / heart.fill |
Icons.Filled.FavoriteBorder / Icons.Filled.Favorite |
| Comment | bubble.left |
Icons.AutoMirrored.Filled.Comment |
| Restack | arrow.2.squarepath |
Icons.Filled.Repeat |
| Save / Bookmark | bookmark / bookmark.fill |
Icons.Filled.BookmarkBorder / Icons.Filled.Bookmark |
| Mute publication | bell.slash |
Icons.Filled.NotificationsOff |
| Unread dot | circle.fill (8pt, orange) |
colored Box/Canvas circle |
| Audio (listen) | headphones |
Icons.Filled.Headphones |
| Subscribed check | checkmark |
Icons.Filled.Check |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21; modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the white reading surface wants dark-content system bars (light-content in dark mode). The read-progress line hugs the top inset; the sticky Subscribe bar sits above the navigation bar inset. - Font scaling:
sphonors the user’s font scale — keep it on the post title, dek, body, section heads (reading content must scale), and apply the in-app reading-size control as an additional multiplier. Pin layout-sensitive chrome (10sp tab labels, eyebrow, captions) by deriving fromdp. - Reading content: never collapse the 1.65 body line-height or the 24.dp measure at large font scales — long reads must stay comfortable.
- TalkBack: the title + dek + byline form a header group; the Subscribe button announces “Subscribe, $8 per month, button”; the like announces state + count; inbox rows announce “{pubName}, {title}, {meta}, unread”; give publication tiles a
contentDescriptionof the publication name. - Touch targets: Material guidance is 48.dp. The Subscribe button is ≥ 44.dp (most important target); action-bar items get a 48.dp hit area via padding; inbox rows are full-row tappable.
- Contrast:
#1F1F1Fon#FFFFFFpasses WCAG AA at 18sp; white-on-orange Subscribe passes AA at 15sp bold; pair the unread dot with row text so meaning never rests on color alone. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, drop the like pop and Subscribe spring, make the paywall blur transition instant — keep the state changes; substituteCrossfade. - Comments are sans: the comment composer and Notes use Inter, not the serif body — they are conversation, not the article. Do not “fix” them to serif.
- Dark mode: invert via the
Dark*palette — soft charcoal#121212, NOT true black;#1F1F1Fbody becomes#EDEDED. The Subscribe card uses the dark orange-tint wash and relies on its 1.dp border (shadows are invisible on dark). The orange#FF6719is constant in every mode — it is the single conversion signal. Do not enable Material YoudynamicColorScheme()— Substack’s paper-and-ink editorial identity and the one rationed orange must hold regardless of wallpaper.