CSS

Download, wire it up, browse every element live — each with the HTML behind it. For Jetpack Compose, see Android.

Download

CSS — drop it in. Zero build step. Font embedded inline.

<link rel="stylesheet" href="https://supernihil.github.io/hardbrut/hardbrut.css">

The font (Inter 900, latin subset) is embedded as base64 — ~31KB of the total (23.9KB of binary font inflates to base64 text). CSS rules alone: ~21KB unminified, ~18KB minified — that's exactly what hardbrut.nofont.css below is. Once loaded, the file makes zero further network requests: no @import, no Google Fonts, no CDN.

Don't want the font? hardbrut.nofont.css (21KB) — same rules, system font stack only. Its own .min.css is ~19KB; minification only meaningfully shrinks the rules, since the embedded font is already binary and can't shrink further.

Previous versions

Getting started

<!DOCTYPE html>
<html lang="en" data-accent="yellow">
<head>
  <link rel="stylesheet" href="hardbrut.css">
</head>
<body>
  <h1>Hello World</h1>
  <button>Click me</button>
</body>
</html>

Accent color: set data-accent on <html> to yellow/red/blue/green/pink/purple (default yellow). Dark mode: set data-theme to light/dark — both persist via a few lines of vanilla JS, no framework:

<button class="theme-toggle" onclick="toggleTheme()">☾</button>
<button class="accent-swatch" data-accent="red" onclick="setAccent('red')"></button>
<script>
var h = document.documentElement;
h.setAttribute('data-theme', localStorage.getItem('theme')
  || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'));
h.setAttribute('data-accent', localStorage.getItem('accent') || 'yellow');
window.toggleTheme = function(){
  var cur = h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  h.setAttribute('data-theme', cur);
  localStorage.setItem('theme', cur);
};
window.setAccent = function(c){
  h.setAttribute('data-accent', c);
  localStorage.setItem('accent', c);
};
</script>

Full wiring (button glyph sync, prefers-color-scheme listener, swatch active-state) is in the page source of every page on this site — view-source works fine, there's no build step to peek behind.

Elements

Every element below is live — try it, then open Show code for the HTML.

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
<div class="card">
  <div class="card-body">
    <h4>Simple Card</h4>
    <p class="text-muted text-sm">One card. No variants. Shadow always.</p>
    <span class="badge">badge</span>
  </div>
</div>

<!-- add a footer for actions -->
<div class="card">
  <div class="card-body"><h4>With Footer</h4></div>
  <div class="card-footer">
    <button>Save</button>
    <button class="cancel">Dismiss</button>
  </div>
</div>

Buttons

Two kinds. Bare <button> uses the accent color; add .btn-cancel for the white one — .btn.btn-cancel is the canonical pattern, works on any tag. On a bare <button> the shorter .cancel alias does the same thing. Every button-like control — including icon toggles — shares one height token (--control-h), so mixed rows always line up.

Link Btn Link Cancel
Show code
<button>Submit</button>
<button class="btn btn-cancel">Cancel</button>
<button class="cancel">Cancel (alias)</button>
<a href="#" class="btn">Link Btn</a>
<a href="#" class="btn btn-cancel">Link Cancel</a>
<button disabled>Disabled</button>

List rows

For a list of clickable rows — conversations, notifications, search results. Truncates gracefully; no configuration.

JD Jane Doe Sounds good, see you at the shadow — no blur, remember? 14:023 MK Mo Khan Pushed the border-radius audit. Found zero, as expected. Yesterday
Show code
<a href="/thread/1" class="list-row">
  <span class="list-row-avatar">JD</span>
  <span class="list-row-body">
    <span class="list-row-title">Jane Doe</span>
    <span class="list-row-subtitle">Sounds good, see you at the shadow</span>
  </span>
  <span class="list-row-meta">
    <span>14:02</span>
    <span class="badge">3</span>
  </span>
</a>

Chat

Two-sided message view. Add .chat-msg-self for outgoing messages — the bubble switches to the accent color automatically, in both themes.

MK
Ship it with hard shadows only, right?
14:00
JD
Always. Zero blur, zero radius.
14:02
Show code
<div class="chat">
  <div class="chat-msg">
    <span class="chat-avatar">MK</span>
    <div>
      <div class="chat-bubble">Ship it with hard shadows only, right?</div>
      <span class="chat-time">14:00</span>
    </div>
  </div>
  <div class="chat-msg chat-msg-self">
    <span class="chat-avatar">JD</span>
    <div>
      <div class="chat-bubble">Always. Zero blur, zero radius.</div>
      <span class="chat-time">14:02</span>
    </div>
  </div>
</div>

Details / Summary

What is HARDBRUT

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

Features (pre-opened)
  • Two button kinds: default + cancel
  • Cards with hard shadows + hover lift
  • List rows + chat bubbles for conversation UIs
  • Switches, chips, tabs, a styled native dialog, progress bar + spinner
  • Details/summary accordion (zero JS)
  • Full markdown element coverage
  • 12-column responsive grid
  • Sticky navbar
  • Auto dark mode + toggle
  • 6 accent color presets
Show code
<details>
  <summary>What is HARDBRUT</summary>
  <p>An opinionated neubrutalist CSS framework.</p>
</details>

<!-- pre-opened -->
<details open>
  <summary>Features</summary>
  <ul><li>...</li></ul>
</details>

Forms

Required.
Show code
<div class="field">
  <label for="name">Name</label>
  <input type="text" id="name" placeholder="Your name">
  <div class="field-hint">Required.</div>
</div>
<div class="field">
  <label><input type="checkbox" class="switch" checked> Notifications</label>
</div>
<div class="field">
  <label for="role">Role</label>
  <select id="role"><option>Admin</option><option>Editor</option></select>
</div>

Typography

Heading 1

Heading 2

Heading 3

Heading 4

Heading 5

Heading 6

Shown here as .text-h1.text-h6 (visual only) so this specimen doesn't inject a second real <h1> into the page outline — use the real <h1><h6> tags for actual headings, same styles either way.

Paragraph with bold (or b), italic (or i), strikethrough (or s), underline and ins, highlight, small, H2O, E=mc2, abbr.

A link in a sentence. Inline code, Ctrl+K, and program output.

Blockquote with 6px black left border and italic text. Perfect for pull quotes.

Diagonal-striped placeholder reading "your image here"
Figure caption — muted, small, sits below the border+shadow image.
Show code
<h2>Heading</h2>
<p>Paragraph with <strong>bold</strong>, <em>italic</em>,
  <mark>highlight</mark>, and <code>inline code</code>.</p>
<blockquote>Pull quote text.</blockquote>

Code

<html data-accent="yellow">
<link rel="stylesheet" href="hardbrut.css">
<button>Click me</button>
Show code
<pre><code>your code here</code></pre>

Lists

  1. Ordered one
  2. Ordered two
Term
Definition. Simple definition list.
Show code
<ul><li>Unordered one</li><li>Unordered two
  <ul><li>Nested</li></ul></li></ul>
<ol><li>Ordered one</li><li>Ordered two</li></ol>
<dl><dt>Term</dt><dd>Definition.</dd></dl>

Table

TokenValueRule
Border3px solidAlways black/ink
Shadow5px 5px 0Hard. No blur.
Radius0Zero. Always.
Show code
<table>
  <thead><tr><th>Token</th><th>Value</th></tr></thead>
  <tbody>
    <tr><td>Border</td><td>3px solid</td></tr>
  </tbody>
</table>

Callout, Message, Badge

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

Status: active

Show code
<div class="callout">Callout. Bold. Accent color.</div>
<div class="message">Message. Smaller padding.</div>
<span class="badge">active</span>

Chips

Selectable pills — filter presets, topic tags. Toggle aria-pressed, CSS does the rest.

Show code
<button class="chip" aria-pressed="true"
  onclick="this.setAttribute('aria-pressed',
    this.getAttribute('aria-pressed')==='true'?'false':'true')">All</button>

Tabs

Panel switching is left to you — toggle each panel's hidden attribute. .tab uses the same accent-fill-means-selected rule as buttons/chips.

Overview panel. Plain content — no required wrapper.
Show code
<div class="tab-list" role="tablist">
  <button class="tab" role="tab" aria-selected="true"
    aria-controls="panel-a" id="tab-a" onclick="hbSelectTab(this,'panel-a')">One</button>
  <button class="tab" role="tab" aria-selected="false"
    aria-controls="panel-b" id="tab-b" onclick="hbSelectTab(this,'panel-b')">Two</button>
</div>
<div class="tab-panel" role="tabpanel" id="panel-a" aria-labelledby="tab-a">...</div>
<div class="tab-panel" role="tabpanel" id="panel-b" aria-labelledby="tab-b" hidden>...</div>

<script>
function hbSelectTab(btn, panelId){
  btn.closest('.tab-list').querySelectorAll('.tab').forEach(function(t){
    t.setAttribute('aria-selected', String(t === btn));
    var panel = document.getElementById(t.getAttribute('aria-controls'));
    if (panel) panel.hidden = (panel.id !== panelId);
  });
}
</script>

Dialog

The native <dialog> element, styled — showModal()/close()/backdrop/Esc-to-dismiss all come free from the browser.

Delete conversation?

This can't be undone.

Show code
<button onclick="document.getElementById('d').showModal()">Open</button>
<dialog id="d">
  <h4>Delete conversation?</h4>
  <div class="cluster">
    <button onclick="document.getElementById('d').close()">Delete</button>
    <button class="cancel" onclick="document.getElementById('d').close()">Cancel</button>
  </div>
</dialog>

Progress & Loading

Determinate: .progress + .progress-fill, width set by you (percent, or drive it from a value/max pair). Segmented look via a repeating background — no discrete DOM elements needed.

Indeterminate: .spinner — hard opacity steps, not a smooth spin, to match the zero-blur motion language. Fully static under prefers-reduced-motion.

Show code
<div class="progress" role="progressbar" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100">
  <div class="progress-fill" style="width:70%"></div>
</div>
<div class="spinner" role="status" aria-label="Loading">
  <span></span><span></span><span></span><span></span>
</div>

Utilities

.text-center, .text-uppercase, .text-muted, .text-sm — text helpers. .stack (column flex) and .cluster (wrapping row flex) — the layout helpers used throughout this page. .sr-only hides content visually while keeping it readable to screen readers.

Centered · uppercase · small

Show code
<p class="text-center text-uppercase text-sm">Centered · uppercase · small</p>
<div class="cluster">...</div> <!-- wrapping row -->
<div class="stack">...</div> <!-- column -->

Grid

.col-4
.col-4
.col-4

12-column grid. 6-col at 768px, stacks at 480px.

Show code
<div class="grid">
  <div class="col-4"><div class="card"><div class="card-body">.col-4</div></div></div>
  <!-- repeat .col-4 for each tile -->
</div>

Recipes

Composing the primitives above into real screens. Copy-pasteable — no build step, no config.

Conversation list

.list-row stacked directly — no wrapper needed. Title and subtitle truncate on their own; give the container a max-width if you want a fixed-width panel.

Show code
<a href="/thread/1" class="list-row">
  <span class="list-row-avatar">JD</span>
  <span class="list-row-body">
    <span class="list-row-title">Jane Doe</span>
    <span class="list-row-subtitle">Let's ship it Thursday</span>
  </span>
  <span class="list-row-meta">
    <span>14:02</span>
    <span class="badge">2</span>
  </span>
</a>

Chat view

Wrap messages in .chat. Add .chat-msg-self to flip a message to the right and switch its bubble to the accent color — no dark-mode CSS needed, it reuses the same accent/accent-ink pairing as buttons and badges.

MK
Ready when you are
14:00
JD
Shipping now
14:02
Show code
<div class="chat">
  <div class="chat-msg">
    <span class="chat-avatar">MK</span>
    <div>
      <div class="chat-bubble">Ready when you are</div>
      <span class="chat-time">14:00</span>
    </div>
  </div>
  <div class="chat-msg chat-msg-self">
    <span class="chat-avatar">JD</span>
    <div>
      <div class="chat-bubble">Shipping now</div>
      <span class="chat-time">14:02</span>
    </div>
  </div>
</div>

Settings screen

A card holding a form. Switches for on/off preferences, chips for a filter row, two button kinds for save/cancel — never more than that.

Notifications

Show code
<div class="card">
  <div class="card-body">
    <h4>Notifications</h4>
    <div class="field">
      <label><input type="checkbox" class="switch" checked> Email me about replies</label>
    </div>
    <div class="field">
      <label>Mute topics</label>
      <div class="cluster">
        <button class="chip" aria-pressed="true">Billing</button>
        <button class="chip" aria-pressed="false">Marketing</button>
      </div>
    </div>
  </div>
  <div class="card-footer">
    <button>Save</button>
    <button class="cancel">Cancel</button>
  </div>
</div>

Dashboard tiles

The 12-column .grid plus .card. Stacks to 6 columns at 768px, 1 column at 480px — automatically, no extra markup.

Open threads

128

Avg. response

4m

Unread

7

Show code
<div class="grid">
  <div class="col-4">
    <div class="card">
      <div class="card-body">
        <p class="text-sm text-muted text-uppercase">Open threads</p>
        <h3 style="margin:0">128</h3>
      </div>
    </div>
  </div>
  <!-- repeat .col-4 for each tile -->
</div>

Rules of thumb