Bandcamp (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Bandcamp’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, the big square album art, the buy/support card, the inline teal-waveform player, the tracklist, the fan collection card, navigation, 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 (Bandcamp’s paper-white editorial page, big square art, the scarce teal reserved for artist links + the buy price + the collection) while making everything idiomatic Android — a
NavigationBarinstead of a UITabBar,Card/Surfaceborders instead of SwiftUI overlays,dp/spinstead ofpt, and Coil for artwork.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for album art. Bandcamp is light-first (the white editorial page is the identity); a true dark inversion is provided but is NOT the default. No Material You dynamic color — the single teal must hold regardless of wallpaper.
1. Color Tokens
// ui/theme/BandcampColors.kt
import androidx.compose.ui.graphics.Color
object BandcampColors {
// Canvas & Surfaces (Light — Bandcamp's true identity)
val Paper = Color(0xFFFFFFFF)
val PaperGray = Color(0xFFF4F4F4)
val HoverGray = Color(0xFFEDEDED)
val Divider = Color(0xFFE2E2E2)
val TrackDivider = Color(0xFFF0F0F0)
// Canvas & Surfaces (Dark — true inversion, NOT default)
val DarkCanvas = Color(0xFF101417) // cool charcoal, slightly blue-gray
val DarkSurface1 = Color(0xFF1A2024)
val DarkSurface2 = Color(0xFF232B30)
val DarkDivider = Color(0xFF2C353B)
// Text
val Ink = Color(0xFF1A1A1A) // near-black — NOT pure black
val TextSecondary = Color(0xFF767676)
val TextTertiary = Color(0xFF9A9A9A)
val DarkInk = Color(0xFFEDF1F3)
// Brand
val Teal = Color(0xFF1DA0C3) // artist links, buy price, Buy button, active tab
val TealDeep = Color(0xFF629AA9)
val TealPress = Color(0xFF17819E)
val DarkTeal = Color(0xFF3DB5D6) // teal shifted lighter for dark-mode contrast
// Semantic
val Success = Color(0xFF4CAF50)
val Error = Color(0xFFE45858)
val Warning = Color(0xFFE0A030)
}
Wire it into both schemes. Bandcamp is light-first; the dark scheme is a true inversion using cool charcoal #101417, never the default.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val BandcampLight = lightColorScheme(
primary = BandcampColors.Teal,
onPrimary = BandcampColors.Paper,
background = BandcampColors.Paper,
onBackground = BandcampColors.Ink,
surface = BandcampColors.PaperGray,
onSurface = BandcampColors.Ink,
surfaceVariant = BandcampColors.HoverGray,
outline = BandcampColors.Divider,
error = BandcampColors.Error,
)
private val BandcampDark = darkColorScheme(
primary = BandcampColors.DarkTeal,
onPrimary = BandcampColors.DarkCanvas,
background = BandcampColors.DarkCanvas,
onBackground = BandcampColors.DarkInk,
surface = BandcampColors.DarkSurface1,
onSurface = BandcampColors.DarkInk,
surfaceVariant = BandcampColors.DarkSurface2,
outline = BandcampColors.DarkDivider,
error = BandcampColors.Error,
)
@Composable
fun BandcampTheme(
dark: Boolean = isSystemInDarkTheme(), // follow system; light is the identity
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) BandcampDark else BandcampLight,
typography = BandcampTypography,
content = content,
)
2. Typography
Bandcamp uses a clean humanist sans; use DM Sans as the faithful fallback (SIL OFL — drop the TTFs in res/font/). Editorial, record-sleeve rhythm — titles at 700, artist/price at 600–700, body at 400.
// ui/theme/BandcampType.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
// Swap dm_sans_* → your licensed Bandcamp face if available.
val DMSans = FontFamily(
Font(R.font.dm_sans_regular, FontWeight.Normal),
Font(R.font.dm_sans_medium, FontWeight.Medium),
Font(R.font.dm_sans_semibold, FontWeight.SemiBold),
Font(R.font.dm_sans_bold, FontWeight.Bold),
)
object BandcampText {
val ScreenTitle = TextStyle(DMSans, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val AlbumTitle = TextStyle(DMSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(DMSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val ArtistLink = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp) // color = Teal at call site
val Subhead = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(DMSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 25.sp)
val Price = TextStyle(DMSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val PriceUnit = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp)
val RowTitle = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Meta = TextStyle(DMSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val TrackName = TextStyle(DMSans, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 19.sp)
val TrackNum = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 12.sp)
val TabLabel = TextStyle(DMSans, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(DMSans, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
}
val BandcampTypography = Typography(
headlineLarge = BandcampText.ScreenTitle,
titleLarge = BandcampText.AlbumTitle,
titleMedium = BandcampText.Subhead,
bodyLarge = BandcampText.Body,
labelSmall = BandcampText.TabLabel,
)
3. Signature Components
Big Square Album Art
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun AlbumArt(url: String?, fullBleed: Boolean = true, modifier: Modifier = Modifier) {
// strictly square, never circular, never tinted, never shadowed — content is sovereign
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(if (fullBleed) 0.dp else 4.dp))
.background(BandcampColors.PaperGray), // flat placeholder, no shimmer
)
}
Buy / Support Card (signature)
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun BuySupportCard(
price: String, currency: String, nameYourPrice: Boolean,
onBuy: () -> Unit, onWishlist: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val haptics = LocalHapticFeedback.current
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(BandcampColors.PaperGray)
.border(1.dp, BandcampColors.Divider, RoundedCornerShape(6.dp))
.padding(16.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("$$price", style = BandcampText.Price, color = BandcampColors.Ink)
Text(currency, style = BandcampText.PriceUnit, color = BandcampColors.TextSecondary)
}
if (nameYourPrice) Text("or more", style = BandcampText.PriceUnit, color = BandcampColors.TextSecondary)
}
if (nameYourPrice) {
Text(
"Name your price · Pay what you want",
style = BandcampText.PriceUnit.copy(fontWeight = FontWeight.Normal),
color = BandcampColors.TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
}
Box(
Modifier
.fillMaxWidth()
.padding(top = 14.dp)
.scale(if (pressed) 0.99f else 1f)
.clip(RoundedCornerShape(4.dp))
.background(if (pressed) BandcampColors.TealPress else BandcampColors.Teal)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onBuy()
}
.padding(vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text("Buy Digital Album", style = BandcampText.Button, color = Color.White)
}
Text(
"Add to wishlist",
style = BandcampText.PriceUnit.copy(color = BandcampColors.Teal),
modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 10.dp).clickable(onClick = onWishlist),
)
}
}
Inline Teal-Waveform Player
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
private val WAVE = listOf(6, 13, 9, 16, 7, 11, 8, 14, 10, 12)
@Composable
fun InlinePlayer(
playing: Boolean, track: String, elapsed: String, total: String,
progress: Float, onToggle: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.border(BorderStroke(0.dp, Color.Transparent)) // banded strip — hairline top+bottom below
.drawBehind {
drawLine(BandcampColors.Divider, androidx.compose.ui.geometry.Offset(0f, 0f), androidx.compose.ui.geometry.Offset(size.width, 0f), 1f)
drawLine(BandcampColors.Divider, androidx.compose.ui.geometry.Offset(0f, size.height), androidx.compose.ui.geometry.Offset(size.width, size.height), 1f)
}
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(36.dp).clip(CircleShape).background(BandcampColors.Ink)
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
tint = Color.White, modifier = Modifier.size(14.dp),
)
}
Column(Modifier.weight(1f)) {
Text(track, style = BandcampText.RowTitle.copy(fontSize = 13.sp), color = BandcampColors.Ink)
Text("$elapsed / $total", style = BandcampText.PriceUnit.copy(fontWeight = FontWeight.Normal, fontSize = 11.sp), color = BandcampColors.TextSecondary)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.height(18.dp)) {
WAVE.forEachIndexed { i, h ->
Box(
Modifier
.width(2.5.dp)
.height(h.dp)
.clip(RoundedCornerShape(1.dp))
.background(BandcampColors.Teal.copy(alpha = if (i.toFloat() / WAVE.size < progress) 1f else 0.35f))
)
}
}
}
}
Tracklist Row
@Composable
fun TrackRow(number: Int, name: String, duration: String, isPlaying: Boolean) {
Column(Modifier.fillMaxWidth().clickable {}.padding(horizontal = 20.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("$number", style = BandcampText.TrackNum, color = BandcampColors.TextTertiary, modifier = Modifier.width(18.dp))
Text(
name,
style = if (isPlaying) BandcampText.TrackName.copy(fontWeight = FontWeight.Bold) else BandcampText.TrackName,
color = if (isPlaying) BandcampColors.Teal else BandcampColors.Ink,
modifier = Modifier.weight(1f),
)
Text(duration, style = BandcampText.Meta.copy(fontSize = 12.sp), color = BandcampColors.TextSecondary)
}
Box(Modifier.fillMaxWidth().height(1.dp).background(BandcampColors.TrackDivider))
}
}
Fan Collection Card
@Composable
fun CollectionCard(artUrl: String?, title: String, artist: String, modifier: Modifier = Modifier) {
Column(modifier) {
AlbumArt(url = artUrl, fullBleed = false) // 4dp radius square
Text(title, style = BandcampText.RowTitle.copy(fontSize = 13.sp), color = BandcampColors.Ink, maxLines = 1, modifier = Modifier.padding(top = 8.dp))
Text(artist, style = BandcampText.Meta.copy(fontSize = 12.sp), color = BandcampColors.TextSecondary, maxLines = 1, modifier = Modifier.padding(top = 2.dp))
}
}
// Feed wrapper: "{Fan} bought this" + optional note above the card
@Composable
fun FeedItem(fan: String, note: String?, content: @Composable () -> Unit) {
Column(Modifier.padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$fan bought this", style = BandcampText.RowTitle.copy(fontSize = 13.sp), color = BandcampColors.Ink)
if (note != null) Text(note, style = BandcampText.Body, color = BandcampColors.TextSecondary)
content()
}
}
4. Navigation
Bandcamp has a 4-tab bottom strip with a teal active tint — no Material pill indicator. The top app bar is a clean surface with a hairline bottom border on scroll.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun BandcampBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = BandcampColors.Paper,
tonalElevation = 0.dp,
) {
val items = listOf(
"Feed" to Icons.Filled.Home,
"Discover" to Icons.Filled.Search,
"Collection" to Icons.Filled.GridView,
"Profile" to Icons.Filled.Person,
)
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 = BandcampText.TabLabel) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = BandcampColors.Teal,
selectedTextColor = BandcampColors.Teal,
unselectedIconColor = BandcampColors.TextTertiary,
unselectedTextColor = BandcampColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Bandcamp is flat & editorial
),
)
}
}
}
@Composable
fun AlbumTopBar(onBack: () -> Unit, onMore: () -> Unit, scrolled: Boolean) {
Surface(color = BandcampColors.Paper, tonalElevation = 0.dp) {
Column {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.ArrowBack, "Back", tint = BandcampColors.Ink,
modifier = Modifier.size(22.dp).clickable(onClick = onBack))
Spacer(Modifier.weight(1f))
Text("Album", style = BandcampText.RowTitle.copy(fontSize = 13.sp), color = BandcampColors.Ink)
Spacer(Modifier.weight(1f))
Icon(Icons.Filled.MoreHoriz, "More", tint = BandcampColors.Ink,
modifier = Modifier.size(22.dp).clickable(onClick = onMore))
}
if (scrolled) Box(Modifier.fillMaxWidth().height(1.dp).background(BandcampColors.Divider))
}
}
}
5. Motion
Bandcamp motion is restrained and editorial — no springs, no big gradients; the page behaves like paper.
| Moment | Compose recipe |
|---|---|
| Buy button press | scale 1 → 0.99 + bg Teal → TealPress tween(100) + HapticFeedbackType.LongPress |
| Add to collection / wishlist | quick heart/check animateFloatAsState scale or alpha tween(150) + soft haptic; snackbar “Added to collection” |
| Page navigation | Nav3/NavHost slide push tween(300); optional shared-element zoom of album art from feed tile |
| Player progress | linear — bar alpha if (i/len < progress) 1f else 0.35f, no animation spec |
| Tracklist row tap | ripple highlight HoverGray, then track name recolors Ink → Teal |
| Bottom sheet (checkout/share) | ModalBottomSheet slide-up tween(300) + scrim fade |
| Tab switch | instant content swap; active icon tints Teal over tween(120) |
// Add-to-collection confirm — the canonical Bandcamp micro-motion
val scale by animateFloatAsState(if (added) 1f else 0.8f, tween(150), label = "collect")
Icon(Icons.Filled.CheckCircle, null, tint = BandcampColors.Teal, modifier = Modifier.scale(scale))
Haptics: use LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on Buy tap, add-to-collection, and wishlist toggle. Purchase success shows a snackbar, not a modal takeover.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. Bandcamp’s iconography is simple/outline-leaning; the waveform is a drawn bar set.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Overflow menu | ellipsis |
Icons.Filled.MoreHoriz |
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Next track | forward.fill |
Icons.Filled.SkipNext |
| Previous track | backward.fill |
Icons.Filled.SkipPrevious |
| Wishlist (add) | heart |
Icons.Outlined.FavoriteBorder |
| Wishlist (added) | heart.fill |
Icons.Filled.Favorite |
| In collection | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| Gift this | gift |
Icons.Filled.CardGiftcard |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Feed (tab) | house.fill |
Icons.Filled.Home |
| Discover (tab) | magnifyingglass |
Icons.Filled.Search |
| Collection (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Profile (tab) | person.fill |
Icons.Filled.Person |
| Download | arrow.down.circle |
Icons.Filled.DownloadForOffline |
| Buy / cart | bag |
Icons.Filled.ShoppingBag |
| Merch | tshirt |
Icons.Filled.Checkroom |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the white canvas wants dark-content system bars in light mode (and light-content in dark). Album art may extend full-bleed under the status bar with the top bar floating. - Font scaling:
sphonors the user’s font scale — keep it on screen/album titles, artist link, body, prices, track names. Pin layout-sensitive text (tab labels, track numbers, captions, price unit) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Font choice: bundle the Bandcamp face if licensed; otherwise ship DM Sans (SIL OFL) — never leave titles to the system default.
- TalkBack: label album art “{Album} by {Artist}, album artwork”; expose the buy card as ONE element announcing “{price} {currency} or more, Buy Digital Album”; tracklist rows as “Track {n}, {name}, {duration}{, now playing}”. The price is critical — never hide it; convey the playing track in the row label, not teal color alone.
- Touch targets: Material guidance is 48.dp. The Buy button is full-width (≥48.dp tall); give the 36.dp play button and 22.dp tab icons a 48.dp hit area; tracklist rows are full-row tappable (~42.dp).
- Contrast:
#1A1A1Aon#FFFFFFpasses WCAG AAA;#1DA0C3on white passes AA for ≥14sp and white-on-teal passes AA for the Buy button;#767676on white passes AA for ≥14sp. In dark mode validate#3DB5D6on#101417. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the album-art zoom and the add-to-collection scale (instant state); player progress is functional, keep it. - Dark mode: a true inversion to
#101417/#EDF1F3with teal →#3DB5D6— but it is NOT the default; followisSystemInDarkTheme(). Do not enable Material YoudynamicColorScheme()— Bandcamp’s single teal (and the paper-editorial identity) must hold regardless of wallpaper. Never tint, dim, or shadow album art — the artwork the artist uploaded must always render full-color and untouched.