coderef

VSCO

VSCO's iOS app is a darkroom, not an app. The entire editing experience lives on a pure-black canvas (#000000 — true black, not a charcoal) because VSCO's core belief is that the photograph is the only thing that should carry color.
DownloadRaw
VSCO

Build with the VSCO look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter, SF Mono
Sections
8
Length
4,465 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · VSCO

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

VSCO (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports VSCO’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the signature hairline slider / film-preset carousel / tool tray, 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 (VSCO’s true-black darkroom, monochrome chrome, hairline bipolar sliders, UPPERCASE wide-tracked labels, the film-preset carousel as the creation gesture) while making everything idiomatic Android — NavigationBar instead of a UITabBar, pointerInput drag instead of a SwiftUI DragGesture, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for photo thumbnails. No color extraction — VSCO’s palette is fixed monochrome, so Palette is not needed. VSCO’s editor is dark-native; there is no Material You dynamic color and no light editor theme.

1. Color Tokens

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

object VSCOColors {
    // Editor surfaces (dark — the editor's only mode)
    val Black     = Color(0xFF000000) // true black — NOT a charcoal
    val Surface1  = Color(0xFF0E0E0E)
    val Surface2  = Color(0xFF1A1A1A)
    val Surface3  = Color(0xFF242424)
    val Divider   = Color(0xFF2A2A2A)
    val Track     = Color(0xFF3A3A3A) // unfilled slider track

    // Browsing screens — light theme (NOT the editor)
    val LightCanvas   = Color(0xFFFFFFFF)
    val LightSurface1 = Color(0xFFF2F2F2)
    val LightSurface2 = Color(0xFFE8E8E8)
    val LightDivider  = Color(0xFFDCDCDC)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFB3B3B3)
    val TextTertiary  = Color(0xFF767676)
    val TextOnLight   = Color(0xFF000000)

    // Interactive — white only (VSCO has NO accent color)
    val White        = Color(0xFFFFFFFF)
    val WhitePressed = Color(0xFFDBDBDB)

    // Incidental film tones (preset-preview content only — never UI fills)
    val FilmWarm    = Color(0xFFC8A86B)
    val FilmCool    = Color(0xFF7E8FA1)
    val FilmNeutral = Color(0xFF9A9388)
    val FilmShadow  = Color(0xFF3F382C)

    // Semantic
    val Destructive = Color(0xFFE5484D)
}

Wire it into both schemes. VSCO’s editor is always the dark scheme; the light scheme is only for the Studio / Discover / Profile browse screens.

// 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 VSCODark = darkColorScheme(
    primary        = VSCOColors.White,          // interactive = white, no accent
    onPrimary      = VSCOColors.Black,
    background     = VSCOColors.Black,           // true black
    onBackground   = VSCOColors.TextPrimary,
    surface        = VSCOColors.Surface1,
    onSurface      = VSCOColors.TextPrimary,
    surfaceVariant = VSCOColors.Surface3,
    outline        = VSCOColors.Divider,
    error          = VSCOColors.Destructive,
)

private val VSCOLight = lightColorScheme(
    primary        = VSCOColors.TextOnLight,
    onPrimary      = VSCOColors.White,
    background      = VSCOColors.LightCanvas,
    onBackground   = VSCOColors.TextOnLight,
    surface        = VSCOColors.LightSurface1,
    onSurface      = VSCOColors.TextOnLight,
    surfaceVariant = VSCOColors.LightSurface2,
    outline        = VSCOColors.LightDivider,
    error          = VSCOColors.Destructive,
)

/** Editor flows MUST pass dark = true regardless of system. */
@Composable
fun VSCOTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) VSCODark else VSCOLight,
    typography = VSCOTypography,
    content = content,
)

2. Typography

VSCO’s brand face is the proprietary “VSCO Gothic”. Bundle Inter (SIL OFL) in res/font/ as the closest free substitute. The signature is UPPERCASE chrome with wide letterSpacing — model that on the TextStyle, not the font file.

// ui/theme/VSCOType.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.text.style.TextGeometricTransform
import androidx.compose.ui.unit.sp

val Inter = FontFamily(
    Font(R.font.inter_regular,  FontWeight.Normal),
    Font(R.font.inter_medium,   FontWeight.Medium),
    Font(R.font.inter_semibold, FontWeight.SemiBold),
    Font(R.font.inter_bold,     FontWeight.Bold),
)

// Named ramp — mirrors DESIGN.md §3 (pt → sp 1:1). Chrome styles carry UPPERCASE intent;
// apply `.copy()` text to uppercase at the call site or use `String.uppercase()`.
object VSCOText {
    // Editorial (sentence-case content screens)
    val Display = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
    val Title   = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
    val Section = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 19.sp, lineHeight = 25.sp, letterSpacing = (-0.1).sp)
    val Body    = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val Label   = TextStyle(Inter, fontWeight = FontWeight.Medium,   fontSize = 14.sp, lineHeight = 20.sp)
    val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 18.sp)

    // Live numeric value
    val Value   = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp)

    // Chrome — UPPERCASE + wide tracking
    val ToolLabel = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, letterSpacing = 1.6.sp)
    val PresetTag = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, letterSpacing = 1.0.sp)
    val NavAction = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, letterSpacing = 1.4.sp)
    val TopTitle  = TextStyle(Inter, fontWeight = FontWeight.Medium,   fontSize = 13.sp, letterSpacing = 1.4.sp)
    val TabLabel  = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, letterSpacing = 0.6.sp)
    val Button    = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, letterSpacing = 1.4.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val VSCOTypography = Typography(
    headlineLarge = VSCOText.Display,
    titleLarge    = VSCOText.Title,
    titleMedium   = VSCOText.Section,
    bodyMedium    = VSCOText.Body,
    labelSmall    = VSCOText.TabLabel,
)

3. Signature Components

Hairline Bipolar Slider (the core atom)

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.shape.CircleShape
import androidx.compose.ui.unit.dp
import kotlin.math.abs

@Composable
fun HairlineSlider(
    toolName: String,
    value: Float,
    onValueChange: (Float) -> Unit,
    min: Float = -6f,
    max: Float = 6f,
    defaultValue: Float = 0f,
    modifier: Modifier = Modifier,
) {
    var trackPx by remember { mutableFloatStateOf(0f) }
    val density = LocalDensity.current
    val haptics = LocalHapticFeedback.current
    val fraction = (value - min) / (max - min)
    val zeroFraction = (0f - min) / (max - min)
    val animFraction by animateFloatAsState(fraction, tween(0), label = "knob")

    Column(modifier.padding(horizontal = 24.dp)) {
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
            Text(toolName.uppercase(), style = VSCOText.ToolLabel, color = VSCOColors.TextSecondary)
            Text(
                if (value >= 0) "+%.1f".format(value) else "%.1f".format(value),
                style = VSCOText.Value, color = VSCOColors.TextPrimary,
            )
        }
        Spacer(Modifier.height(12.dp))
        Box(
            Modifier
                .fillMaxWidth()
                .height(44.dp)
                .onSizeChanged { trackPx = it.width.toFloat() }
                .pointerInput(Unit) {
                    detectHorizontalDragGestures { change, _ ->
                        val f = (change.position.x / trackPx).coerceIn(0f, 1f)
                        onValueChange(min + f * (max - min))
                    }
                }
                .pointerInput(Unit) {
                    detectTapGestures(onDoubleTap = {
                        haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                        onValueChange(defaultValue)
                    })
                },
            contentAlignment = Alignment.CenterStart,
        ) {
            Box(Modifier.fillMaxWidth().height(1.dp).background(VSCOColors.Track))
            // white fill from zero-point to knob
            with(density) {
                val left = minOf(animFraction, zeroFraction) * trackPx
                val widthPx = abs(animFraction - zeroFraction) * trackPx
                Box(
                    Modifier
                        .offset(x = left.toDp())
                        .width(widthPx.toDp())
                        .height(1.dp)
                        .background(VSCOColors.White)
                )
                Box(
                    Modifier
                        .offset(x = (animFraction * trackPx).toDp() - 9.dp)
                        .size(18.dp)
                        .shadow(4.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.6f))
                        .background(VSCOColors.White, CircleShape)
                )
            }
        }
    }
}

Film-Preset Carousel (the signature)

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush

data class VSCOPreset(val code: String, val preview: List<Color>)

@Composable
fun PresetCarousel(
    presets: List<VSCOPreset>,
    selected: Int,
    onSelect: (Int) -> Unit,
) {
    val haptics = LocalHapticFeedback.current
    Row(
        Modifier
            .horizontalScroll(rememberScrollState())
            .padding(horizontal = 20.dp, vertical = 14.dp),
        horizontalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        presets.forEachIndexed { i, p ->
            val active = i == selected
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(7.dp),
                modifier = Modifier.clickable {
                    onSelect(i)
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                },
            ) {
                Box(
                    Modifier
                        .size(56.dp)
                        .clip(RoundedCornerShape(2.dp))
                        .background(Brush.linearGradient(p.preview))
                        .then(
                            if (active) Modifier.border(2.dp, VSCOColors.White, RoundedCornerShape(2.dp))
                            else Modifier
                        )
                )
                Text(
                    p.code.uppercase(),
                    style = VSCOText.PresetTag,
                    color = if (active) VSCOColors.TextPrimary else VSCOColors.TextSecondary,
                )
            }
        }
    }
}

Tool Tray

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector

data class VSCOTool(val icon: ImageVector, val label: String)

@Composable
fun ToolTray(tools: List<VSCOTool>, active: Int, onSelect: (Int) -> Unit) {
    Box(
        Modifier
            .fillMaxWidth()
            .height(84.dp)
            .background(VSCOColors.Black)
            .drawTopHairline(VSCOColors.Divider),
    ) {
        Row(
            Modifier
                .horizontalScroll(rememberScrollState())
                .padding(horizontal = 26.dp)
                .fillMaxHeight(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(30.dp),
        ) {
            tools.forEachIndexed { i, t ->
                val tint = if (i == active) VSCOColors.TextPrimary else VSCOColors.TextSecondary
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                    modifier = Modifier
                        .widthIn(min = 44.dp)
                        .clickable { onSelect(i) },
                ) {
                    Icon(t.icon, contentDescription = t.label, tint = tint, modifier = Modifier.size(22.dp))
                    Text(t.label.uppercase(), style = VSCOText.ToolLabel, color = tint)
                }
            }
        }
    }
}

// 0.5dp top hairline helper
fun Modifier.drawTopHairline(color: Color) = drawWithContent {
    drawContent()
    drawLine(color, start = Offset(0f, 0f), end = Offset(size.width, 0f), strokeWidth = 1f)
}

4. Editor Top Bar

Notion-style minimal chrome equivalent: a 52.dp bar with a close glyph, an UPPERCASE tracked title, and a bare “NEXT” text action — no filled button on the bar.

import androidx.compose.material.icons.filled.Close

@Composable
fun EditorTopBar(onClose: () -> Unit, onNext: () -> Unit) {
    Row(
        Modifier
            .fillMaxWidth()
            .height(52.dp)
            .background(VSCOColors.Black)
            .padding(horizontal = 20.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Icon(
            Icons.Filled.Close, contentDescription = "Close",
            tint = VSCOColors.TextPrimary,
            modifier = Modifier.size(24.dp).clickable { onClose() },
        )
        Text("EDIT", style = VSCOText.TopTitle, color = VSCOColors.TextSecondary)
        Text(
            "NEXT", style = VSCOText.NavAction, color = VSCOColors.TextPrimary,
            modifier = Modifier.clickable { onNext() },
        )
    }
}

The Studio grid is a LazyVerticalGrid(GridCells.Fixed(4)) on Surface1 with 1.dp item spacing, square Coil thumbnails, and no corner radius — a gapless contact sheet. Multi-select adds a 2.dp white inset border + a white Icons.Filled.Check.

5. Navigation

VSCO has minimal chrome: a 4-tab bottom strip (icon-only) and full-screen flows. On Android model the strip as a NavigationBar. There is no tint pill — active is just white.

import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*

@Composable
fun VSCOBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = VSCOColors.Black,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            Icons.Filled.GridView,
            Icons.Filled.ArrowUpward,
            Icons.Filled.PhotoCamera,
            Icons.Filled.Person,
        )
        items.forEachIndexed { i, icon ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = null, modifier = Modifier.size(23.dp)) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = VSCOColors.White,       // white, no tint
                    unselectedIconColor = VSCOColors.TextTertiary,
                    indicatorColor      = Color.Transparent,      // NO Material pill — VSCO has none
                ),
            )
        }
    }
}

6. Motion

VSCO motion is a quiet 180–300ms cross-fade vocabulary; before/after is instant.

Moment Compose recipe
Preset apply two stacked Images; top graphicsLayer { alpha } → animateFloatAsState(tween(250, easing = FastOutSlowInEasing))
Slider drag 1:1 detectHorizontalDragGestures; value text updates live; soft haptic on zero-detent crossing
Slider double-tap reset onDoubleTap; value animateFloatAsState(tween(200)) back to default + soft haptic
Tool switch carousel slideOutHorizontally(tween(180)) + slider fadeIn(tween(180)) (8dp offset)
Before/after press pointerInput press-down shows original, release returns — tween(0) (no animation)
Tab switch Crossfade(tween(200)) — no slide
Sheet present ModalBottomSheet slide up tween(300)
Multi-select toggle white inset border + check scaleIn(tween(150)) per tile
// Preset cross-dissolve — the canonical VSCO motion
val topAlpha by animateFloatAsState(if (presetApplied) 1f else 0f, tween(250), label = "grade")
Box {
    Image(/* original */)
    Image(/* graded */, modifier = Modifier.graphicsLayer { alpha = topAlpha })
}

Haptics: use LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on preset selection, double-tap reset, and multi-select toggle. For the zero-detent tick while dragging, prefer LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK). Save-to-gallery is silent — show a “Saved” snackbar with no motion or haptic.

7. Icons

VSCO ships custom thin-line tool glyphs; the closest first-party set is androidx.compose.material:material-icons-extended (Outlined variants). There is no brand-accent tint — every icon is TextPrimary (active) or TextSecondary (inactive).

Purpose SF Symbol (iOS) Material Icon (Compose)
Close editor xmark Icons.Filled.Close
Filters tool camera.filters Icons.Outlined.FilterVintage
Exposure tool sun.max Icons.Outlined.LightMode
Contrast tool circle.lefthalf.filled Icons.Outlined.Contrast
Saturation tool drop Icons.Outlined.WaterDrop
Sharpen tool triangle Icons.Outlined.Details
Crop tool crop Icons.Outlined.Crop
Straighten tool crop.rotate Icons.Outlined.Straighten
Grain tool circle.grid.3x3 Icons.Outlined.Grain
HSL tool paintpalette Icons.Outlined.Palette
Studio (tab) square.grid.2x2 Icons.Filled.GridView
Feed (tab) arrow.up Icons.Filled.ArrowUpward
Discover (tab) camera Icons.Filled.PhotoCamera
Profile (tab) person Icons.Filled.Person
Import square.and.arrow.down Icons.Filled.FileDownload
Settings gearshape Icons.Filled.Settings
Recipe plus.rectangle.on.rectangle Icons.Filled.AddBox
Multi-select check checkmark Icons.Filled.Check

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; gesture + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the editor wants light-content system bars on its true-black canvas. The photo stage must shrink to fit inside the system insets — apply Modifier.windowInsetsPadding(WindowInsets.safeDrawing) to the editor scaffold so the photo is never occluded by the top bar, tool tray, or gesture nav.
  • The editor is dark-native: never pass dark = false to VSCOTheme for editing flows — a light editor breaks color judgement. Only Studio/Discover/Profile may follow isSystemInDarkTheme().
  • No Material You: do not enable dynamicColorScheme() — VSCO’s monochrome darkroom identity must hold regardless of wallpaper; there is no brand accent to harmonize with one.
  • Font scaling: sp honors the user’s font scale — keep it on editorial titles, sections, body, captions. Pin layout-sensitive chrome (tool labels, preset codes, slider value, nav actions, 10sp tab labels) by deriving from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: give the slider Modifier.semantics { role = Role.Slider; stateDescription = "$value"; setProgress { ... } } so it is adjustable without the drag; preset tiles get selected = active semantics; the before/after press needs a customActions = listOf(CustomAccessibilityAction("Show original", ...)).
  • Touch targets: Material guidance is 48.dp. The 18.dp slider knob, 22.dp tool glyphs, and 23.dp tab icons all need a 48.dp hit area via padding/sizeIn; Studio thumbnails are fully tappable; the top-bar “NEXT” action needs a 48.dp tap region.
  • Contrast: #FFFFFF on #000000 is maximal (AAA). Secondary #B3B3B3 on black passes AA for the small UPPERCASE chrome at 11–12sp; reserve #767676 for tertiary/disabled only.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace preset cross-fades and the tool-switch slide with an instant Crossfade/cut; the slider reset jumps; keep the white selection ring (it conveys state, not motion).
  • Slider knob shadow: keep the shadow(4.dp, ..., spotColor = Color.Black.copy(alpha = 0.6f)) — it is functional so the white knob stays visible over bright photo regions, not decorative.
  • Dark mode: the editor is permanently #000000 with #FFFFFF text/controls; only the browse theme inverts to #FFFFFF / #000000. Shadows are nearly invisible on true black — separation comes from the lighter #1A1A1A sheet surface, not elevation.

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