coderef

Life360

Life360's iOS app is a map-first family safety app, and almost everything about its design serves one job: let a parent open the app and instantly see where everyone is. The home screen is a full-bleed interactive map.
DownloadRaw
Life360

Build with the Life360 look.

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

Palette

At a glance

Theme
dark
Typefaces
Plus Jakarta Sans
Sections
7
Length
4,483 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Life360

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

Life360 (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Life360’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the family-map chrome + member sheet, 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 (Life360’s violet night, the dark-styled map, fixed per-member identity colors, the floating Circle pill, the draggable member sheet) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a BottomSheetScaffold instead of SwiftUI detents, Google Maps Compose, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, maps-compose4.3+, and Coil 2.6+ for member photos. No color extraction — member identity colors are assigned data, brand is fixed; Palette is not used. A light scheme is provided; dark (violet night) is the default.

1. Color Tokens

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

object Life360Colors {
    // Canvas & Surfaces (Dark — default)
    val Canvas   = Color(0xFF161325) // violet-tinted night
    val Surface1 = Color(0xFF1F1B33)
    val Surface2 = Color(0xFF2A2542)
    val Sheet    = Color(0xFF211D36)
    val Divider  = Color(0xFF332E4D)
    val Border   = Color(0xFF3D3759)

    // Map palette (dark-styled — keeps member pins salient)
    val MapBase  = Color(0xFF1A2138)
    val MapRoad  = Color(0xFF2C3654)
    val MapPark  = Color(0xFF1B3A2E)
    val MapWater = Color(0xFF16314A)
    val MapLabel = Color(0xFF7E7A99)

    // Surfaces (light)
    val CanvasLight  = Color(0xFFFFFFFF)
    val SurfaceLight = Color(0xFFF5F3FA)
    val MapBaseLight = Color(0xFFEDEFF5)
    val DividerLight = Color(0xFFE6E3F0)

    // Text
    val TextPrimary   = Color(0xFFECEAF5)
    val TextSecondary = Color(0xFFA6A0C2)
    val TextTertiary  = Color(0xFF6F6990)
    val TextOnLight   = Color(0xFF1C1830)

    // Brand (Life360 Purple — NEVER a member color)
    val Purple        = Color(0xFF582C83)
    val PurpleLight   = Color(0xFF7E57C2)
    val PurpleBright  = Color(0xFF8B5CF6)
    val PurplePressed = Color(0xFF4A2470)

    // Safety semantic (reserved — rationed)
    val Safe    = Color(0xFF34C759)
    val SOS     = Color(0xFFFF6B6B)
    val Warning = Color(0xFFFFB020)
}

// Member identity palette — assign in Circle join order; fixed per person everywhere.
val MemberPalette = listOf(
    Color(0xFFF2A33C), // amber
    Color(0xFF2DD4BF), // teal
    Color(0xFFF472B6), // pink
    Color(0xFF60A5FA), // blue
    Color(0xFFFB7185), // coral
    Color(0xFFA3E635), // lime
    Color(0xFF38BDF8), // sky
)
fun memberColor(slot: Int): Color = MemberPalette[slot % MemberPalette.size]
fun batteryColor(pct: Int): Color = if (pct <= 20) Life360Colors.SOS else Life360Colors.Safe

Wire it into both schemes. Life360 is violet-night dark by default; a day-styled light scheme is provided.

// 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 Life360Dark = darkColorScheme(
    primary        = Life360Colors.PurpleLight,  // brand spine
    onPrimary      = Color.White,
    background     = Life360Colors.Canvas,
    onBackground   = Life360Colors.TextPrimary,
    surface        = Life360Colors.Surface1,
    onSurface      = Life360Colors.TextPrimary,
    surfaceVariant = Life360Colors.Surface2,
    outline        = Life360Colors.Divider,
    error          = Life360Colors.SOS,
)

private val Life360Light = lightColorScheme(
    primary        = Life360Colors.PurpleLight,
    onPrimary      = Color.White,
    background      = Life360Colors.CanvasLight,
    onBackground   = Life360Colors.TextOnLight,
    surface        = Life360Colors.SurfaceLight,
    onSurface      = Life360Colors.TextOnLight,
    surfaceVariant = Color(0xFFEDEAF5),
    outline        = Life360Colors.DividerLight,
    error          = Life360Colors.SOS,
)

@Composable
fun Life360Theme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) Life360Dark else Life360Light,
    typography = Life360Typography,
    content = content,
)

2. Typography

Life360’s brand face is friendly rounded-humanist; Plus Jakarta Sans is the closest free analog (SIL OFL — drop the TTFs in res/font/). Single brand face, no user switching; member names bold for a reassuring tone.

// ui/theme/Life360Type.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

val PlusJakarta = FontFamily(
    Font(R.font.plus_jakarta_sans_regular,   FontWeight.Normal),
    Font(R.font.plus_jakarta_sans_medium,    FontWeight.Medium),
    Font(R.font.plus_jakarta_sans_semibold,  FontWeight.SemiBold),
    Font(R.font.plus_jakarta_sans_bold,      FontWeight.Bold),
    Font(R.font.plus_jakarta_sans_extrabold, FontWeight.ExtraBold),
)

// Named ramp — mirrors DESIGN.md §3 (pt → sp 1:1)
object Life360Text {
    val Display    = TextStyle(PlusJakarta, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
    val Title      = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold,      fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
    val Section    = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold,      fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val MemberName = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold,      fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val Body       = TextStyle(PlusJakarta, fontWeight = FontWeight.Normal,    fontSize = 16.sp, lineHeight = 24.sp)
    val RowTitle   = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold,  fontSize = 15.sp, lineHeight = 20.sp)
    val Status     = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold,  fontSize = 13.sp, lineHeight = 17.sp)
    val Meta       = TextStyle(PlusJakarta, fontWeight = FontWeight.Normal,    fontSize = 14.sp, lineHeight = 19.sp)
    val Caption    = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold,  fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp)
    val PinInitial = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold,      fontSize = 16.sp, lineHeight = 16.sp)
    val Tab        = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold,  fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Button     = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold,      fontSize = 15.sp, lineHeight = 15.sp)
    val SOS        = TextStyle(PlusJakarta, fontWeight = FontWeight.ExtraBold, fontSize = 15.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val Life360Typography = Typography(
    headlineLarge  = Life360Text.Display,
    headlineMedium = Life360Text.Title,
    titleMedium    = Life360Text.RowTitle,
    bodyMedium     = Life360Text.Body,
    labelSmall     = Life360Text.Tab,
)

3. Signature Components

Member Pin (the atomic Life360 element)

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun MemberPin(
    initial: String,
    color: Color,            // the member's FIXED identity color
    photoUrl: String? = null,
    selected: Boolean = false,
) {
    val pulse by animateFloatAsState(if (selected) 1.04f else 1f, spring(), label = "pinPulse")
    Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(pulse)) {
        Box(
            Modifier
                .size(46.dp)
                .shadow(12.dp, CircleShape, ambientColor = Color.Black, spotColor = Color.Black)
                .clip(CircleShape)
                .background(color)
                .border(if (selected) 4.dp else 3.dp, Color.White, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            if (photoUrl != null) {
                AsyncImage(photoUrl, null, Modifier.size(40.dp).clip(CircleShape))
            } else {
                Text(initial, style = Life360Text.PinInitial, color = Color.White)
            }
        }
        // downward pointer
        Canvas(Modifier.size(12.dp, 8.dp)) {
            val p = Path().apply {
                moveTo(size.width / 2f, size.height); lineTo(0f, 0f); lineTo(size.width, 0f); close()
            }
            drawPath(p, Color.White)
        }
    }
}

Member Sheet Row

import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.Icon

@Composable
fun MemberRow(
    initial: String,
    color: Color,
    name: String,
    placeText: String,
    battery: Int,
    onClick: () -> Unit,
) {
    val bc = batteryColor(battery)
    Row(
        Modifier.fillMaxWidth().heightIn(min = 62.dp).clickable { onClick() }
            .padding(horizontal = 18.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(Modifier.size(42.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) {
            Text(initial, style = Life360Text.RowTitle, color = Color.White)
        }
        Column(Modifier.weight(1f)) {
            Text(name, style = Life360Text.MemberName, color = Life360Colors.TextPrimary)
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
                Icon(Icons.Filled.LocationOn, null, tint = Life360Colors.TextSecondary, modifier = Modifier.size(11.dp))
                Text(placeText, style = Life360Text.Status, color = Life360Colors.TextSecondary)
            }
        }
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
            BatteryGlyph(battery, bc)
            Text("$battery%", style = Life360Text.Status, color = bc)
        }
    }
}

@Composable
fun BatteryGlyph(level: Int, color: Color) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Box(
            Modifier.size(22.dp, 11.dp)
                .border(1.5.dp, color, RoundedCornerShape(3.dp))
                .padding(1.5.dp),
            contentAlignment = Alignment.CenterStart,
        ) {
            Box(Modifier.fillMaxHeight().fillMaxWidth(level / 100f).clip(RoundedCornerShape(1.dp)).background(color))
        }
        Box(Modifier.size(2.dp, 4.dp).background(color))
    }
}

Circle Selector Pill

import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Notifications

@Composable
fun CircleSelector(
    circleName: String,
    hasAlerts: Boolean,
    onSwitch: () -> Unit,
    onBell: () -> Unit,
) {
    Row(
        Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(999.dp))
            .background(Life360Colors.Surface1.copy(alpha = 0.92f)) // Android has no live blur — opaque-ish pill
            .border(1.dp, Life360Colors.Border, RoundedCornerShape(999.dp))
            .shadow(20.dp, RoundedCornerShape(999.dp), spotColor = Color.Black.copy(alpha = 0.45f))
            .padding(horizontal = 16.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Row(
            Modifier.clickable { onSwitch() },
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(7.dp),
        ) {
            Text(circleName, style = Life360Text.RowTitle.copy(fontWeight = FontWeight.Bold), color = Life360Colors.TextPrimary)
            Icon(Icons.Filled.ExpandMore, null, tint = Life360Colors.TextSecondary, modifier = Modifier.size(13.dp))
        }
        Box(
            Modifier.size(30.dp).clip(CircleShape).background(Life360Colors.Surface2).clickable { onBell() },
            contentAlignment = Alignment.Center,
        ) {
            Icon(Icons.Filled.Notifications, "Alerts", tint = Life360Colors.TextPrimary, modifier = Modifier.size(15.dp))
            if (hasAlerts) Box(Modifier.align(Alignment.TopEnd).size(8.dp).clip(CircleShape).background(Life360Colors.SOS))
        }
    }
}

Primary & SOS Buttons

import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import kotlinx.coroutines.delay

@Composable
fun Life360PrimaryButton(title: String, onClick: () -> Unit) {
    Button(
        onClick = onClick,
        shape = RoundedCornerShape(28.dp),
        colors = ButtonDefaults.buttonColors(containerColor = Life360Colors.PurpleLight, contentColor = Color.White),
        contentPadding = PaddingValues(horizontal = 26.dp, vertical = 14.dp),
    ) { Text(title, style = Life360Text.Button) }
}

@Composable
fun SOSButton(onTrigger: () -> Unit) {
    var progress by remember { mutableFloatStateOf(0f) }
    var holding by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current
    val animated by animateFloatAsState(if (holding) 1f else 0f,
        animationSpec = androidx.compose.animation.core.tween(1500), label = "sosFill")

    LaunchedEffect(holding) {
        if (holding) { delay(1500); if (holding) { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onTrigger() } }
    }
    Box(
        Modifier
            .clip(RoundedCornerShape(28.dp))
            .background(Life360Colors.SOS.copy(alpha = 0.55f))
            .pointerInput(Unit) {
                detectTapGestures(onPress = {
                    holding = true; tryAwaitRelease(); holding = false
                })
            },
    ) {
        Box(Modifier.matchParentSize().fillMaxWidth(animated).background(Life360Colors.SOS))
        Text("SOS", style = Life360Text.SOS, color = Color.White,
            modifier = Modifier.padding(horizontal = 26.dp, vertical = 14.dp))
    }
}

4. Navigation + Member Sheet

Life360 has a 4-tab bottom strip and a draggable member sheet over a full-bleed map. On Android, model the strip as a NavigationBar (active = Purple Light, no tint pill) and the sheet as a BottomSheetScaffold.

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

@Composable
fun Life360BottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = Life360Colors.Sheet, tonalElevation = 0.dp) {
        val items = listOf(
            "Map"     to Icons.Filled.LocationOn,
            "Places"  to Icons.Filled.Home,
            "Driving" to Icons.Filled.DirectionsCar,
            "Safety"  to Icons.Filled.Shield,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, label, Modifier.size(21.dp)) },
                label = { Text(label, style = Life360Text.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = Life360Colors.PurpleLight,   // brand
                    selectedTextColor = Life360Colors.PurpleLight,
                    unselectedIconColor = Life360Colors.TextTertiary,
                    unselectedTextColor = Life360Colors.TextTertiary,
                    indicatorColor = Color.Transparent,              // no Material pill — purple is enough
                ),
            )
        }
    }
}

Use BottomSheetScaffold with sheetPeekHeight = 180.dp, sheetContainerColor = Life360Colors.Sheet, sheetShape = RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp), and a 38.dp drag handle. The map is GoogleMap (maps-compose) with a dark MapStyleOptions JSON using MapBase/MapRoad/MapPark/MapWater; member pins are MarkerComposables rendering MemberPin. Place geofences are Circle overlays — fillColor = PurpleLight.copy(alpha = 0.16f), strokeColor = PurpleLight.copy(alpha = 0.55f), strokeWidth = 2.dp.

5. Motion

Life360 motion is calm and reassuring — pins glide, sheets snap softly, red is the only urgent note.

Moment Compose recipe
Pin movement animate the marker LatLng with Animatable over ~600ms tween(easing = FastOutSlowIn); pulse scale 1→1.08→1 keyframes
Map auto-frame cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding), 400)
Pin tap → focus cameraPositionState.animate(...center..., 350) + grow ring (border 3→4dp) + member glow
Member sheet BottomSheetScaffold drag → nearest anchor spring(dampingRatio = 0.85f, stiffness = Medium)
Geofence entry ripple overlay Circle radius animateFloatAsState 1→1.06 + alpha 1→0 tween(350)
Breadcrumb draw animate the Polyline point count head→tail over ~500ms
Tab switch Crossfade(tween(200)); the GoogleMap persists underneath
// Pin glide — the canonical Life360 motion (never teleport)
val lat = remember { Animatable(start.latitude.toFloat()) }
val lng = remember { Animatable(start.longitude.toFloat()) }
LaunchedEffect(target) {
    launch { lat.animateTo(target.latitude.toFloat(), tween(600, easing = FastOutSlowInEasing)) }
    launch { lng.animateTo(target.longitude.toFloat(), tween(600, easing = FastOutSlowInEasing)) }
}

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the sheet detent snap and the SOS completion; view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) for pin-tap selection; for a new Alert-red notification fire HapticFeedbackConstants.REJECT. The SOS hold should emit an escalating series of ticks while filling.

6. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. The member pin and battery glyph are custom Canvas/Box drawings, not icon glyphs.

Purpose SF Symbol (iOS) Material Icon (Compose)
Map (tab) mappin.and.ellipse Icons.Filled.LocationOn
Places (tab) house.fill Icons.Filled.Home
Driving (tab) car.fill Icons.Filled.DirectionsCar
Safety (tab) shield.fill Icons.Filled.Shield
Member at place mappin.circle.fill Icons.Filled.LocationOn
Member driving clock.fill Icons.Filled.Schedule
Circle chevron chevron.down Icons.Filled.ExpandMore
Notification bell bell.fill Icons.Filled.Notifications
Add place plus Icons.Filled.Add
Recenter map location.fill Icons.Filled.MyLocation
Place — Home house.fill Icons.Filled.Home
Place — School graduationcap.fill Icons.Filled.School
Place — Work briefcase.fill Icons.Filled.Work
Driving event exclamationmark.triangle.fill Icons.Filled.Warning
Crash detected car.side.and.exclamationmark Icons.Filled.CarCrash
Check in checkmark.circle.fill Icons.Filled.CheckCircle
Invite person.badge.plus Icons.Filled.PersonAdd
Back chevron.left Icons.Filled.ArrowBack

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; BottomSheetScaffold + maps-compose comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+, maps-compose 4.3+.
  • Edge-to-edge: call enableEdgeToEdge(); the full-bleed dark map wants light-content system bars. Only chrome respects insets — the Circle pill below the cutout, the member sheet + NavigationBar above the gesture nav; the map draws under both. Forms use Modifier.imePadding().
  • Map styling is mandatory: apply a dark MapStyleOptions JSON (MapBase/MapRoad/MapPark/MapWater) so member pins are the brightest objects. Use a day style only in light mode. Do not enable Material You dynamicColorScheme() — member identity colors + brand purple + safety colors are semantic and must not be wallpaper-tinted.
  • Member identity color is data, not theme: persist each member’s slot/color on the member model; reuse it for the pin ring, sheet avatar, breadcrumb Polyline, and driving-report trail. Never recompute per screen.
  • Font scaling: sp honors the user’s font scale — keep it on Display/Title/Section/MemberName/Body/RowTitle and let member rows grow. Pin layout-sensitive text (pin initials, 10sp tab labels, map/place-tag text) via CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: announce a pin/row as “{name}, at {place}, {time} ago, battery {n} percent” via Modifier.semantics(mergeDescendants = true); the identity color is decorative — the name carries identity so color-blind users are never reliant on hue.
  • Color is not the only signal: member name always accompanies the color; battery shows a percentage + glyph fill, not just red/green; safety state has text (“Left School”), not just a red dot.
  • SOS accessibility: the press-and-hold must have an alternative for motor-impaired users — expose an “Emergency” action in the Safety tab and a Modifier.semantics { onClick(...) } / custom action on the button so it’s triggerable without a sustained long-press.
  • Touch targets: Material guidance is 48.dp. Member rows are full-row tappable (≥62.dp); the pin visible bubble ≥46.dp; the Circle pill ≥44.dp; tab items get a 48.dp hit area; the SOS button ≥52.dp.
  • Contrast: #ECEAF5 on #161325 ≈ 14:1 (AAA). Member colors back white initials / are pin rings — validated for the white initial; never use a member hue as small text on the dark canvas.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, snap pins instead of gliding, skip the geofence ripple and breadcrumb draw, keep the sheet snap; never disable the SOS haptic ramp (it is a safety affordance).
  • Dark mode: the violet night + dark map is the default. The light scheme swaps canvas/map to the day set, but member colors, brand purple, Safe green, and SOS red are identical across modes (they carry meaning).
All mobile designs →

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