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.
badgeWith 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.
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.
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.
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
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.
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
- Unordered one
- Unordered two
- Nested
- Ordered one
- 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
| Token | Value | Rule |
|---|---|---|
| Border | 3px solid | Always black/ink |
| Shadow | 5px 5px 0 | Hard. No blur. |
| Radius | 0 | Zero. 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
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.
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.
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
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.
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
- Reach for
.list-rowand.chatas-is — don't wrap them in.card, they already carry their own border/shadow. .list-row-title/.list-row-subtitletruncate to one line by design. If you need full text, use plain text inside.list-row-bodyinstead.- Selected/toggled state always means "filled with
--accent, text in--accent-ink" —.chip[aria-pressed="true"],.switch:checked, buttons, badges all follow the same rule. Don't invent a second "selected" color. - Every shadow-bearing element keeps its shadow always — it only disappears on
:active. Don't strip shadows for a "flatter" look; that's a different framework. - Motion is hard-edged, never smooth — button press is a translate + shadow snap, not an ease;
.spinnersteps opacity, it doesn't rotate.