coderef

Substack

Substack's iOS app is built around a single belief: the writing is the product, and the writer's voice deserves a page that looks like a fine magazine, not a feed.
DownloadRaw
Substack

Build with the Substack look.

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

Palette

At a glance

Theme
light
Typefaces
Inter, Source Serif 4
Sections
7
Length
4,014 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Substack

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

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 — NavigationBar instead of a UITabBar, ModalBottomSheet instead of a SwiftUI sheet, sp/dp instead of pt.

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 BOM 2024.09+, Kotlin 2.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: sp honors 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 from dp.
  • 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 contentDescription of 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: #1F1F1F on #FFFFFF passes 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; substitute Crossfade.
  • 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; #1F1F1F body 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 #FF6719 is constant in every mode — it is the single conversion signal. Do not enable Material You dynamicColorScheme() — Substack’s paper-and-ink editorial identity and the one rationed orange must hold 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