ANDROID

HARDBRUT for Jetpack Compose — drop Hardbrut.kt into your project. Every element below renders the CSS version live; the Kotlin is in Show code. For the web version, see CSS.

HARDBRUT translates directly to Jetpack Compose. Drop Hardbrut.kt into your Android project — one file, one dependency (Compose). Same rules: zero corner radius, hard shadows, two button kinds, always-shadow rule, single accent color.

Download

Download for Android

One Kotlin file. Drop it in. Import and use.

Previous versions

Getting started

// Drop Hardbrut.kt into your project (one file, one dependency: Compose)
val (accent, accentInk) = HardbrutTokens.AccentYellow

HardbrutButton(onClick = { /* do something */ }, accent = accent, accentInk = accentInk) {
    Text("CLICK ME")
}

Compose has no CSS-like text-transform — every composable that takes a raw String (badge, chip, list row) uppercases it internally. If you build custom content with Text() yourself, uppercase the label at the call site.

CSS tokenCSS valueCompose
--ink#000000HardbrutTokens.Ink
--paper#FFFFFFHardbrutTokens.Paper
--bg#FDFAF2HardbrutTokens.Background
--accentvariesHardbrutTokens.AccentYellow.first
--border3px solidBorderStroke(3.dp, Ink)
--shadow5px 5px 0.hardShadow(5.dp)
Hard shadow helper
fun Modifier.hardShadow(size: Dp, color: Color = Color.Black): Modifier = this.then(
    drawBehind {
        drawRect(color = color, topLeft = Offset(size.toPx(), size.toPx()),
            size = Size(this.size.width, this.size.height))
    }
)

Elements

Every element below is the CSS render, live — Compose doesn't run in a browser, so this is the closest live preview until a Compose Multiplatform build exists. Open Show code for the Kotlin.

Cards

Simple Card

One card. No variants. Shadow always.

badge

With Footer

Footer puts actions at the bottom.

Just Body

Content only. Border. Shadow.

Show code
@Composable
fun HardbrutCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)

HardbrutCard {
    Text("SIMPLE CARD", fontWeight = FontWeight.ExtraBold)
    Text("One card. No variants. Shadow always.",
        color = HardbrutTokens.Muted, fontSize = 14.sp)
    HardbrutBadge("badge", accent = accent, accentInk = accentInk)
}

Buttons

Two kinds, same call either way — a kind parameter, not a second composable.

Show code
@Composable
fun HardbrutButton(onClick: () -> Unit, accent: Color, accentInk: Color,
    kind: ButtonKind = ButtonKind.Default, enabled: Boolean = true,
    modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit)
enum class ButtonKind { Default, Cancel }

HardbrutButton(onClick = { }, accent = accent, accentInk = accentInk) { Text("SUBMIT") }
HardbrutButton(onClick = { }, accent = accent, accentInk = accentInk,
    kind = ButtonKind.Cancel) { Text("CANCEL") }
HardbrutButton(onClick = { }, accent = accent, accentInk = accentInk,
    enabled = false) { Text("DISABLED") }

List rows

JD Jane Doe Sounds good, see you at the shadow — no blur, remember? 14:023
Show code
@Composable
fun HardbrutListRow(title: String, subtitle: String? = null,
    avatarText: String? = null, onClick: (() -> Unit)? = null,
    trailing: @Composable (RowScope.() -> Unit)? = null,
    modifier: Modifier = Modifier)

HardbrutListRow(
    title = "Jane Doe",
    subtitle = "Sounds good, see you at the shadow",
    avatarText = "JD",
    onClick = { openThread(id) },
    trailing = { Text("14:02", fontSize = 12.sp, color = HardbrutTokens.Muted) }
)

Chat

MK
Ship it with hard shadows only, right?
14:00
JD
Always. Zero blur, zero radius.
14:02
Show code
// No dedicated chat-bubble composable yet — compose one from a Box using
// hardShadow(), same accent-flip rule ("self" message = filled accent) as
// everything else:
@Composable
fun ChatBubble(text: String, self: Boolean, accent: Color, accentInk: Color) {
    Box(
        Modifier
            .hardShadow(3.dp)
            .border(2.dp, HardbrutTokens.Ink)
            .background(if (self) accent else HardbrutTokens.Paper)
            .padding(HardbrutTokens.SpaceSm)
    ) {
        Text(text, color = if (self) accentInk else HardbrutTokens.Ink)
    }
}

Details / Summary

What is HARDBRUT

An opinionated neubrutalist CSS framework. Every shadow-bearing element keeps its shadow — only gone during a press animation.

Show code
@Composable
fun HardbrutDetails(summary: String, expanded: Boolean,
    onToggle: () -> Unit, content: @Composable () -> Unit)

var expanded by remember { mutableStateOf(false) }
HardbrutDetails(
    summary = "What is HARDBRUT",
    expanded = expanded,
    onToggle = { expanded = !expanded }
) {
    Text("An opinionated neubrutalist design system.")
}

Forms

Required.
Show code
@Composable
fun HardbrutTextField(value: String, onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier, label: String = "", placeholder: String = "")
@Composable
fun HardbrutCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit)
@Composable
fun HardbrutRadio(selected: Boolean, onClick: () -> Unit)
@Composable
fun HardbrutSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit,
    accent: Color, accentInk: Color)

var name by remember { mutableStateOf("") }
HardbrutTextField(value = name, onValueChange = { name = it },
    label = "Name", placeholder = "Your name")

var notifications by remember { mutableStateOf(true) }
HardbrutSwitch(notifications, { notifications = it }, accent, accentInk)

// No dropdown/select composable yet — style Compose's ExposedDropdownMenuBox
// with the same border/shadow tokens as HardbrutTextField.

Typography

Heading

Paragraph with bold, italic, highlight, and inline code.

Pull quote text.
Show code
// No dedicated typography composables — Text() plus these tokens covers it.
Text("HEADING", fontWeight = FontWeight.Black, fontSize = 28.sp,
    letterSpacing = (-0.5).sp)
Text("Paragraph with bold and italic.", fontSize = 17.sp, lineHeight = 24.sp)
Text(
    "Pull quote text.",
    fontStyle = FontStyle.Italic,
    modifier = Modifier
        .drawBehind { drawRect(Color.Black, size = Size(6.dp.toPx(), size.height)) }
        .padding(start = HardbrutTokens.SpaceSm)
)

Code

<html data-accent="yellow">
<link rel="stylesheet" href="hardbrut.css">
<button>Click me</button>
Show code
// No dedicated code-block composable — a bordered, shadowed Box with a
// monospace Text() reproduces <pre><code>:
Box(
    Modifier.border(2.dp, HardbrutTokens.Ink).hardShadow(5.dp)
        .background(HardbrutTokens.Paper).padding(HardbrutTokens.Space)
) {
    Text(codeString, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
}

Lists

  1. Ordered one
  2. Ordered two
Show code
// No dedicated list composable — a Column of Rows reproduces bullets:
Column {
    listOf("Unordered one", "Unordered two").forEach { item ->
        Row { Text("•  "); Text(item) }
    }
}
// Ordered: swap "•" for the 1-based index string instead.

Table

TokenValueRule
Border3px solidAlways black/ink
Shadow5px 5px 0Hard. No blur.
Show code
// No dedicated table composable — a header Row (ink background, paper
// text) plus body Rows reproduces the CSS table look:
Column(Modifier.border(2.dp, HardbrutTokens.Ink)) {
    Row(Modifier.background(HardbrutTokens.Ink).padding(8.dp)) {
        Text("TOKEN", color = HardbrutTokens.Paper, fontWeight = FontWeight.Black)
        Text("VALUE", color = HardbrutTokens.Paper, fontWeight = FontWeight.Black)
    }
    Row(Modifier.padding(8.dp)) { Text("Border"); Text("3px solid") }
}

Callout, Message, Badge

Callout. Bold. Accent color. Shadow always.
Message. Same accent. Smaller padding.

Status: active

Show code
@Composable
fun HardbrutCallout(text: String, accent: Color, accentInk: Color)
@Composable
fun HardbrutBadge(text: String, accent: Color, accentInk: Color)

HardbrutCallout("Heads up — this can't be undone.", accent = accent, accentInk = accentInk)
HardbrutBadge("active", accent = accent, accentInk = accentInk)
// Message: same as Callout with tighter padding — no separate composable
// yet, wrap the callout content in a Box with less vertical padding.

Chips

Show code
@Composable
fun HardbrutChip(text: String, selected: Boolean, onClick: () -> Unit,
    accent: Color, accentInk: Color)

var selected by remember { mutableStateOf("all") }
HardbrutChip("All", selected = selected == "all",
    onClick = { selected = "all" }, accent = accent, accentInk = accentInk)
HardbrutChip("Open", selected = selected == "open",
    onClick = { selected = "open" }, accent = accent, accentInk = accentInk)

Tabs

Overview panel.
Show code
@Composable
fun HardbrutTabRow(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
    accent: Color, accentInk: Color)

var tab by remember { mutableStateOf(0) }
HardbrutTabRow(tabs = listOf("Overview", "Details"), selected = tab,
    onSelect = { tab = it }, accent = accent, accentInk = accentInk)
when (tab) {
    0 -> Text("Overview panel")
    1 -> Text("Details panel")
}

Dialog

Delete conversation?

This can't be undone.

Show code
@Composable
fun HardbrutDialog(onDismissRequest: () -> Unit,
    content: @Composable ColumnScope.() -> Unit)

var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
    HardbrutDialog(onDismissRequest = { showDialog = false }) {
        Text("Delete conversation?", fontWeight = FontWeight.ExtraBold)
        Spacer(Modifier.height(HardbrutTokens.SpaceSm))
        HardbrutButton(onClick = { showDialog = false },
            accent = accent, accentInk = accentInk) { Text("DELETE") }
    }
}

Progress & Loading

Show code
@Composable
fun HardbrutProgressBar(progress: Float, modifier: Modifier = Modifier,
    segments: Int = 12, accent: Color)
@Composable
fun HardbrutSpinner(modifier: Modifier = Modifier)

HardbrutProgressBar(progress = 0.7f, accent = accent)
HardbrutSpinner()

Utilities

Centered · uppercase · small

Show code
// .stack / .cluster have no dedicated composables — use Column /
// Row(horizontalArrangement = Arrangement.spacedBy(...)) directly.
Text("CENTERED · UPPERCASE · SMALL", textAlign = TextAlign.Center, fontSize = 13.sp)

Grid

.col-4
.col-4
.col-4
Show code
// No dedicated grid composable — Row/Column with Modifier.weight(), or
// LazyVerticalGrid(columns = GridCells.Fixed(3)) for scrolling grids.
Row(horizontalArrangement = Arrangement.spacedBy(HardbrutTokens.SpaceSm)) {
    repeat(3) { HardbrutCard(modifier = Modifier.weight(1f)) { Text(".col-4") } }
}

Recipes

Composing composables into real screens.

Primary + cancel buttons

val (accent, accentInk) = HardbrutTokens.AccentYellow

Row(horizontalArrangement = Arrangement.spacedBy(HardbrutTokens.SpaceSm)) {
    HardbrutButton(onClick = { /* save */ }, accent = accent, accentInk = accentInk) {
        Text("SAVE")
    }
    HardbrutButton(
        onClick = { /* dismiss */ },
        accent = accent, accentInk = accentInk,
        kind = ButtonKind.Cancel
    ) { Text("CANCEL") }
}

Conversation list

HardbrutListRow mirrors the CSS .list-row — avatar, title, subtitle (single-line, ellipsized), and a trailing slot for a timestamp or badge.

LazyColumn(verticalArrangement = Arrangement.spacedBy(HardbrutTokens.SpaceSm)) {
    items(threads) { thread ->
        HardbrutListRow(
            title = thread.name,
            subtitle = thread.lastMessage,
            avatarText = thread.initials,
            onClick = { openThread(thread.id) },
            trailing = {
                Column(horizontalAlignment = Alignment.End) {
                    Text(thread.time, fontSize = 12.sp, color = HardbrutTokens.Muted)
                    if (thread.unread > 0) {
                        HardbrutBadge(thread.unread.toString(), accent = accent, accentInk = accentInk)
                    }
                }
            }
        )
    }
}

Settings screen

Switches and chips read the same accent pair as everything else — pass it down once.

val (accent, accentInk) = HardbrutTokens.AccentYellow
var emailOn by remember { mutableStateOf(true) }
var mutedTopic by remember { mutableStateOf<String?>(null) }

HardbrutCard {
    Text("NOTIFICATIONS", fontWeight = FontWeight.ExtraBold)
    Spacer(Modifier.height(HardbrutTokens.SpaceSm))
    Row(verticalAlignment = Alignment.CenterVertically) {
        HardbrutSwitch(emailOn, { emailOn = it }, accent, accentInk)
        Spacer(Modifier.width(8.dp))
        Text("Email me about replies")
    }
    Spacer(Modifier.height(HardbrutTokens.SpaceSm))
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        HardbrutChip("Billing", selected = mutedTopic == "billing",
            onClick = { mutedTopic = "billing" }, accent = accent, accentInk = accentInk)
        HardbrutChip("Marketing", selected = mutedTopic == "marketing",
            onClick = { mutedTopic = "marketing" }, accent = accent, accentInk = accentInk)
    }
}

Dashboard tiles

Row(horizontalArrangement = Arrangement.spacedBy(HardbrutTokens.SpaceSm)) {
    listOf("Open threads" to "128", "Avg. response" to "4m", "Unread" to "7")
        .forEach { (label, value) ->
            HardbrutCard(modifier = Modifier.weight(1f)) {
                Text(label.uppercase(), fontSize = 12.sp, color = HardbrutTokens.Muted)
                Text(value, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
            }
        }
}

Dark mode

Hardbrut.kt ships light-only tokens today — there's no HardbrutTokens dark variant yet. If your app supports dark mode, branch on isSystemInDarkTheme() and swap the three theme-dependent tokens, mirroring the CSS [data-theme="dark"] block:

val isDark = isSystemInDarkTheme()
val ink = if (isDark) Color(0xFFF0F0E8) else HardbrutTokens.Ink
val paper = if (isDark) Color(0xFF1A1A18) else HardbrutTokens.Paper
val background = if (isDark) Color(0xFF121210) else HardbrutTokens.Background
// accent / accentInk stay the same in both themes — same as the web version

Every composable takes accent/accentInk as parameters rather than reading a global, so swapping ink/paper/background at the call site is enough — nothing internal needs to change.

Rules of thumb

Spacing

All spacing uses: HardbrutTokens.SpaceSm (12dp), .Space (24dp), .SpaceLg (40dp).