coderef

Bandcamp

Bandcamp's iOS app is a paper-first, editorial music marketplace whose entire identity is built on one belief: the album artwork — uploaded by the artist — is the brand, and the UI exists to get out of its way.
DownloadRaw
Bandcamp

Build with the Bandcamp look.

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

Palette

At a glance

Theme
light
Typefaces
DM Sans
Sections
7
Length
4,170 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Bandcamp

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

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 NavigationBar instead of a UITabBar, Card/Surface borders instead of SwiftUI overlays, dp/sp instead of pt, 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 BOM 2024.09+, Kotlin 2.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: sp honors 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 from dp or CompositionLocalProvider(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: #1A1A1A on #FFFFFF passes WCAG AAA; #1DA0C3 on white passes AA for ≥14sp and white-on-teal passes AA for the Buy button; #767676 on white passes AA for ≥14sp. In dark mode validate #3DB5D6 on #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 / #EDF1F3 with teal → #3DB5D6 — but it is NOT the default; follow isSystemInDarkTheme(). Do not enable Material You dynamicColorScheme() — 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.
All mobile designs →

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