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 token | CSS value | Compose |
|---|---|---|
--ink | #000000 | HardbrutTokens.Ink |
--paper | #FFFFFF | HardbrutTokens.Paper |
--bg | #FDFAF2 | HardbrutTokens.Background |
--accent | varies | HardbrutTokens.AccentYellow.first |
--border | 3px solid | BorderStroke(3.dp, Ink) |
--shadow | 5px 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.
badgeWith 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
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
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
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
- Unordered one
- Unordered two
- Ordered one
- 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
| Token | Value | Rule |
|---|---|---|
| Border | 3px solid | Always black/ink |
| Shadow | 5px 5px 0 | Hard. 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
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
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
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
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
- Every composable takes
accent/accentInkexplicitly — there's no global theme object to reach for. Pick aHardbrutTokens.Accent*pair once per screen and thread it through. HardbrutButtonuses a custom black-15%-alpha ripple, not the platform default — don't overrideindicationyourself unless you're deliberately deviating from the spec inANDROID.md.- Android's
Modifier.shadow()can't do zero blur — every HARDBRUT composable usesModifier.hardShadow()instead (drawn as a flat offset rect). Don't mix inModifier.shadow(); the blur will look inconsistent next to everything else. HardbrutTabRowandHardbrutDialogonly handle chrome — panel switching (tabs) and dismiss wiring (dialog) are yours, same division of labor as the CSS versions.
Spacing
All spacing uses: HardbrutTokens.SpaceSm (12dp), .Space (24dp), .SpaceLg (40dp).