Every class, variant and state, with the markup to copy.

Link bevel.css before your own stylesheet. Everything in it sits in @layer bevel.*, so your CSS, written outside any layer, overrides it without !important. States take the class or the ARIA attribute: .active or aria-current, .disabled or aria-disabled, .error or aria-invalid.

<link rel="stylesheet" href="/static/bevel-0.2.0.css">
<link rel="stylesheet" href="/static/app.css">

Every custom property starts with --bv-. The swatches below read the live values, so an override in your app shows up here too.

:root {
  --bv-color-link: #0066A0;
}

Neutrals

Action and focus

Navbar

Labels and badges

Alerts

Form errors and code

A heading sits 9px above what it introduces and 18px below whatever came before it, so it always reads with the block under it. At the top of a column, alert or well it gets no space above.

h1. Heading 1

h2. Heading 2

h3. Heading 3

h4. Heading 4

h5. Heading 5
h6. Heading 6

Apps Five on hearth-01

Deploy Ship something new

Backups The last one finished 6 hours ago

Body text

The lead paragraph: 20px on a 27px line, for the one sentence that sets up a page.

A longer paragraph wraps at about 70 characters a line, however wide the page. Each app keeps a small manifest next to its code, and hearth reads it to decide how to pull, build and run the app, which port it listens on, and whether it should come back after the machine restarts. Everything else has a sensible default, so most manifests are four or five lines long.

Code blocks

# hearth.toml
name = "recipes"
source = "git@github.com:you/recipes.git"
port = 8086
start_on_boot = true
2026-10-08T14:02:11Z eink-sync exited with status 1: open /var/lib/eink-sync/state/cache/2026-10/display-frame-buffer.bin: no such file or directory (retrying in 30s)

Monospace

code and pre use --bv-font-mono: the first of Menlo, Monaco, Consolas and Courier New that this machine has, or else its own monospace font. The block below is set in whichever one that is.

ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 0O 1lI| ({[<>]}) => != :: ; , . ' " ` ~ ! @ # $ % ^ & * _ + - = / \

Each font in the stack on its own. A sample in a serif face means this machine doesn’t have that font.

FontShips withSample
MenlomacOS 10.6 and later, iOS0O 1lI {} =>
MonacomacOS0O 1lI {} =>
ConsolasWindows Vista and later0O 1lI {} =>
Courier NewWindows, macOS0O 1lI {} =>
monospaceEvery system, as its default monospace font0O 1lI {} =>

Rule

An <hr> is 2.0’s etched line.


The .container centers content at 724px, 940px or 1170px, and is fluid below 768px. Each .section starts 60px below the one before; the first sits 18px below the navbar. A .page-header opens each section.

<main class="container">
  <section class="section">
    <div class="page-header"><h1>Apps <small>Five apps on hearth-01</small></h1></div>
    <div class="row cols-3">…</div>
  </section>
</main>

Rows

With no modifier, .row fits as many equal columns as it can, each at least 220px wide. A row ends with 18px, like a table or an alert, whatever its columns end with: the last block in each column drops its own bottom margin.

One
Two
Three
Four
Five

Fixed columns

.cols-2, .cols-3 and .cols-4 fix the count from 768px up and stack below it.

.cols-2
.cols-2
.cols-3
.cols-3
.cols-3
.cols-4
.cols-4
.cols-4
A column with more text than the others wraps inside its own track and never pushes the grid wider.

Variants

Blue is the one action you most expect someone to take; red is the one they can’t undo.

Elements

.btn works the same on <button>, <a> and <input type="submit">.

Sizes

With an icon

Pressed

Pressed buttons lose their gradient and sink. Use .active, or aria-pressed="true" on a toggle.

Disabled

Use the disabled attribute on buttons and inputs. Links can’t be disabled natively, so give them .disabled together with aria-disabled="true" and no href.

Disabled link

Groups and toolbars

A .btn-group joins buttons into one control. A .btn-toolbar lines up groups and loose buttons, and wraps when it runs out of room.

<div class="btn-toolbar">
  <div class="btn-group" role="group" aria-label="Filter apps">
    <button class="btn" aria-pressed="true">All</button>
    <button class="btn">Running</button>
    <button class="btn">Stopped</button>
  </div>
  <button class="btn btn-primary">Deploy an app</button>
</div>

A bare field already looks right. .control-group spaces a label, field and help text; .error or aria-invalid="true" marks a problem. Group radios and related checkboxes in a <fieldset> with a <legend>: it draws no box, and the legend reads like a label.

Fields

Lowercase letters, numbers and dashes.

States

Taken by chess-trainer. Try 8086.

With aria-invalid alone, the field turns red; the label and help keep their color.

This branch has no commits yet.

Read-only: you can select and copy it.

Keep logs for
<div class="control-group error">
  <label for="port">Port</label>
  <input id="port" class="input-small" type="text" value="8081"
         aria-invalid="true" aria-describedby="port-help">
  <p class="help-block" id="port-help">Taken by chess-trainer. Try 8086.</p>
</div>
<div class="form-actions">
  <button class="btn btn-primary">Deploy</button>
  <button class="btn">Cancel</button>
</div>

Add-ons

Wrap a field and a .add-on in .input-prepend or .input-append to put a unit or a fixed part of the value beside it. The add-on goes on whichever side it sits in the markup, so the two classes work the same and both can wrap a field with one on each side. The field keeps its width modifier. A screen reader doesn’t read the add-on as part of the label, so give it an id and add that to the field’s aria-describedby.

$
MB
https:// .hearth.local
git@
GB

Only 74 GB is free on hearth-01.

days
<label for="budget">Monthly budget</label>
<div class="input-prepend">
  <span class="add-on" id="budget-unit">$</span>
  <input id="budget" class="input-small" type="text" aria-describedby="budget-unit">
</div>

Default

AppStatusPortNote
chess-trainerRunning8081Moved to the new opening book last week; the old one is still in the repository if anything breaks.
moneyRunning8082
notebookDeploying8083

Striped and bordered

Wrap a table in .table-scroll and it scrolls inside its own box on small screens instead of widening the page. Give the wrapper role="region", an aria-label and tabindex="0", so keyboard users can focus it and scroll with the arrow keys.

#AppStatusPortVersionDeployedActions
1chess-trainerRunning8081v0.9.22 hours ago
2moneyRunning8082v1.4.0yesterday
3notebookDeploying8083v0.3.1just now
4feedsStopped8084v2.0.0
5eink-syncFailed8085v0.1.03 days ago

Condensed

TimeEventDuration
14:02eink-sync exited with status 10.4 s
12:10chess-trainer deployed41.2 s
08:00Backup finished6 m 12 s
03:30Nightly restart

Bordered, no header

Hosthearth-01
Uptime18 days, 4 hours
Disk118 of 192 GB used
Kernel

Wide

AppCPUMemoryDiskRequests / dayErrors / dayRepositoryLast log line
chess-trainer1.2%48 MB210 MB1,2040github.com/you/chess-trainerserved /puzzles/daily in 12ms
money0.3%31 MB1.4 GB882github.com/you/moneyimported 14 transactions from the October statement
<div class="table-scroll" role="region" aria-label="Apps on hearth-01" tabindex="0">
  <table class="table table-bordered table-striped">
    <thead><tr><th>App</th><th>Status</th><th class="text-right">Port</th></tr></thead>
    <tbody>
      <tr><td>chess-trainer</td><td><span class="label label-success">Running</span></td><td class="text-right">8081</td></tr>
    </tbody>
  </table>
</div>

A <nav class="pagination"> around a list of page links. Mark the current page with aria-current="page"; it sinks in like a pressed button. A page you can’t go to, such as Previous on the first page, is a <span>, and so is a gap between page numbers. 2.x markup, with .active and .disabled on the <li>, works too.

<nav class="pagination" aria-label="Pages">
  <ul>
    <li><span><span aria-hidden="true">&lsaquo;</span> Previous</span></li>
    <li><a href="?page=1" aria-current="page">1</a></li>
    <li><a href="?page=2">2</a></li>
    <li><a href="?page=2">Next <span aria-hidden="true">&rsaquo;</span></a></li>
  </ul>
</nav>

Use role="alert" for errors that need attention now and role="status" for everything else.

Disk: over 60% full. This is the default warning alert.
Deployed. chess-trainer v0.9.2 went live 2 hours ago.
Heads up! notebook is deploying.

Backups are running late

The nightly backup took 41 minutes, up from 6. Something large may have landed in a data folder.

<div class="alert alert-error alert-block" role="alert">
  <h4 class="alert-heading">eink-sync stopped responding</h4>
  <p>It exited with status 1 three days ago.</p>
</div>
<div class="alert alert-info" role="status"><strong>Heads up!</strong> notebook is deploying.</div>

Labels carry a word and badges carry a number; the color only reinforces it.

VariantLabelBadge
DefaultStopped1
SuccessRunning2
WarningDeploying4
ImportantFailed6
InfoScheduled8
InverseArchived10

Backups 7

OK The last backup finished 6 hours ago. 128 files changed since.

<span class="label label-success">Running</span>
<span class="badge badge-important">2</span>

A .progress track holds a .bar; set the bar’s width inline. The track is faint on white, so say the value in words too, above the bar or elsewhere in its table row. Give the track role="progressbar" for work under way, such as a backup, or role="meter" for a level, such as disk space or a budget, with aria-valuenow, aria-valuemin, aria-valuemax and a name.

Disk: 118 of 192 GB used

Groceries: $212.47 of $450.00

Dining out: $184.30 of $200.00

Subscriptions: $71.97 of $60.00

Backing up money: 1.1 of 1.4 GB (.progress-striped.active)

Importing 3 statements: 1 done (.progress-striped)

Nothing backed up yet

A level past its maximum fills the track: set aria-valuenow to the maximum and say the real value in aria-valuetext, as the Subscriptions bar does. The stripes move only on .active, and stop for anyone who asks for less motion.

In a table

In a cell, a bar drops its bottom margin and is at least 90px wide, so a column of bars keeps its size.

CategorySpentBudgetUsed
Groceries$212.47$450.00
Dining out$184.30$200.00
Subscriptions$71.97$60.00
<p id="disk">Disk: 118 of 192 GB used</p>
<div class="progress" role="meter" aria-labelledby="disk"
     aria-valuenow="118" aria-valuemin="0" aria-valuemax="192">
  <div class="bar" style="width: 61%"></div>
</div>

Add .hero-unit when a page needs a large callout, such as an app’s home page; leave it out and nothing changes. The heading is 60px from 768px up and 36px below it. Paragraphs inside are 18px on a 27px line.

Welcome to hearth

Five apps on hearth-01. Each one deploys with a git push and comes back on its own after the machine restarts.

Deploy an app

<div class="hero-unit">
  <h1>Welcome to hearth</h1>
  <p>Five apps on hearth-01.</p>
  <p><a class="btn btn-primary btn-large" href="/deploy">Deploy an app</a></p>
</div>

With a grid

Add .hero-unit-grid to swap the grey fill for the 18px grid on white, ruled like the band at the top of this page, with a faint edge.

Welcome to hearth

Five apps on hearth-01. Each one deploys with a git push and comes back on its own after the machine restarts.

Deploy an app

<div class="hero-unit hero-unit-grid">
  <h1>Welcome to hearth</h1>
  <p>Five apps on hearth-01.</p>
  <p><a class="btn btn-primary btn-large" href="/deploy">Deploy an app</a></p>
</div>

Add .stats to a <dl> for the few numbers a home page opens with: each <dt> is a muted label and each <dd> a 30px figure. Make the list a .row too, with each pair in a <div>, to set the figures side by side. A <small> in a figure holds its unit or the rest of a fraction.

Apps running
4 of 5
Disk used
118 of 192 GB
Up for
18 days
Last backup
6 hours ago

In wells

Give each pair .well to set it in a panel of its own.

Requests today
1,292
Errors today
2
Slowest response
412 ms
<dl class="row cols-4 stats">
  <div><dt>Apps running</dt><dd>4 <small>of 5</small></dd></div>
  <div><dt>Disk used</dt><dd>118 <small>of 192 GB</small></dd></div>
</dl>

A .well sets a block into the page: something to keep beside the main content without competing with it. Its last block drops its bottom margin, so the padding alone sets the space inside.

hearth-01

Five apps, 118 of 192 GB used, up 18 days. The next backup starts at 03:30.

Empty states

Add .well-empty and put the well where a list would be when there’s nothing in it: say what would be here, and how to add the first one. Text is centered and muted, with two rhythm lines above and below.

No apps on hearth-01 yet. Each app deploys with a git push and comes back on its own after a restart.

Deploy an app

Backups

Nothing backed up yet. The first backup runs tonight at 03:30.

Failed deploys

None in the last 30 days.

Search

No apps match “receipe”.

Show all apps

<div class="well well-empty">
  <p><strong>No apps on hearth-01 yet.</strong> Each app deploys with a git push.</p>
  <p><a class="btn btn-primary" href="/deploy">Deploy an app</a></p>
</div>

Confirm before deleting

A button can’t ask “are you sure?” without JavaScript: a <dialog> needs a script to open. So the button that starts a delete is a plain .btn link to a confirmation page of its own, with an ellipsis because there’s another step. That page says what will go, in numbers where it can, and holds the only red button, which posts the form. The way back says what it does rather than “Cancel”. Every step works with the back button, and nothing is deleted by a GET.

The button that starts it, under the thing it deletes:

Erase all data…

The page it opens:

This deletes everything in money, and money can’t bring it back.

Accounts
4
Transactions
163
Budgets
7
Imported statements
23

Last night’s hearth backup keeps a copy for 30 days.

Keep my data
<!-- GET /settings: -->
<a class="btn" href="/settings/erase">Erase all data&hellip;</a>

<!-- GET /settings/erase: -->
<div class="page-header"><h1>Erase all data?</h1></div>
<p class="lead">This deletes everything in money, and money can&rsquo;t bring it back.</p>
<dl class="row cols-4 stats">…</dl>
<form action="/settings/erase" method="post">
  <div class="form-actions">
    <button class="btn btn-danger" type="submit">Erase all data</button>
    <a class="btn" href="/settings">Keep my data</a>
  </div>
</form>

.muted sets text in the muted grey, for asides and timestamps. It keeps 4.5:1 on white and on bevel’s grey surfaces: striped and hovered table rows, the hero unit and the form actions bar.

.text-right aligns to the end of the line, for numbers and trailing actions. On a paragraph it also lifts the line-length cap, so the text reaches the end.

.table-scroll wraps a wide table; see Tables.

.visually-hidden keeps text for screen readers and hides it from view: a label the page already makes clear, or the words for an icon-only button. The search field and the buttons below each have one.

<label class="visually-hidden" for="filter">Filter apps</label>
<input id="filter" type="search" placeholder="Filter apps">