Surfaces & data
Boxes and rows that hold content — cards, lists, tables, numbers.
Cards
.k-card is a surface with border, radius, and
the token shadow. .k-card__kicker is a small
mono label above the heading. Two quieter variants:
--flat keeps the border but drops the shadow, and
--tint trades the surface for the soft accent —
no border, no shadow.
Padding has two presets: --pad-sm tightens it,
--pad-lg opens it up — both just set the
--k-card-pad knob for you. --accent adds
a 3px accent-colored top edge to flag one card without shouting.
And --row lays a card out horizontally — a figure or
icon beside the body, flexed with a gap — which folds back to a
vertical stack under 40rem.
Cards read knobs too — --k-card-bg,
--k-card-border, --k-card-radius, and
--k-card-pad — so a one-off compact card is a single
inline knob:
<div class="k-card" style="--k-card-pad: var(--k-space-3)">.
Kicker
A card
Body content is plain HTML; the card trims the margin of its last child so padding stays even.
Without a kicker
The kicker is optional. Cards are usually laid
out with .k-grid, as here.
Flat
.k-card--flat — border only, no
shadow. For dense grids where elevation gets noisy.
Tint
.k-card--tint — the soft accent as
the surface. A quiet way to highlight one card.
Compact
.k-card--pad-sm — tighter padding for
dense grids.
Roomy
.k-card--pad-lg — more breathing room
when a card carries weight.
Accent edge
.k-card--accent — a 3px top edge in the
accent color.
Horizontal layout
.k-card--row puts a figure or icon
beside the body; under 40rem it stacks vertically.
Markup
<div class="k-card">
<p class="k-card__kicker">Kicker</p>
<h4>A card</h4>
<p>Body content.</p>
</div>
<!-- padding presets and an accent top edge -->
<div class="k-card k-card--pad-sm">…</div>
<div class="k-card k-card--pad-lg">…</div>
<div class="k-card k-card--accent">…</div>
<!-- horizontal: media beside body, stacks under 40rem -->
<div class="k-card k-card--row">
<span class="k-icon k-icon--box" aria-hidden="true"></span>
<div>
<h4>Horizontal layout</h4>
<p>Body content.</p>
</div>
</div>
Surface effects
Glassmorphism and friends are not components — they're
treatments any surface can wear. .k-glass
frosts whatever it's on (real backdrop-filter, with
a solid-surface fallback where unsupported — it needs something
behind it to show); .k-glow is an accent halo;
.k-bg-mesh is a soft multi-point gradient built from
both your seeds. All derived from tokens, so every effect stays
on-brand automatically, and every one keeps text contrast intact.
Glass
Frosted, on-system
A .k-card.k-glass over
.k-bg-mesh — the blur is real.
Glow
An accent halo
Derived from your accent's transparencies — retint and the glow follows.
Markup
<div class="k-bg-mesh">
<div class="k-card k-glass">…</div>
<div class="k-card k-glow">…</div>
</div>
<!-- a frosted sticky header -->
<header class="k-glass k-sticky-top">…</header>
<!-- blur amount is a knob -->
<div class="k-card k-glass" style="--k-blur: 24px">…</div>
Panel
.k-panel is a card with structural regions:
__header, __body, and
__footer. The difference from .k-card
is exactly that structure — a card is one padded box, while a
panel's header and footer are tinted strips with flush borders
that run edge to edge (the panel clips its own corners, so the
regions need no radius of their own). Use it when a surface has
a title bar or a row of actions that belong to the box, not the
content.
Two variants: --pad-sm tightens the section
padding (it sets the --k-panel-pad knob), and
--accent turns the header into an accent-colored bar
with on-accent text — for a panel that should read as the primary
one in a set.
Deploy settings
Body content is plain HTML, padded like a card; the panel trims its last child's margin so the spacing stays even.
Primary plan
.k-panel--accent paints the header bar;
.k-panel--pad-sm tightens the padding.
Markup
<div class="k-panel">
<div class="k-panel__header"><h4>Deploy settings</h4></div>
<div class="k-panel__body">
<p>Body content.</p>
</div>
<div class="k-panel__footer">
<button class="k-btn k-btn--small" type="button">Save</button>
</div>
</div>
<!-- accent header bar, tighter padding -->
<div class="k-panel k-panel--accent k-panel--pad-sm">
<div class="k-panel__header"><h4>Primary plan</h4></div>
<div class="k-panel__body"><p>Body content.</p></div>
</div>
Calendar
.k-calendar is a month grid:
__head holds the month name and a pair of
.k-btn--icon .k-btn--ghost buttons,
__grid is seven abbr weekday cells
followed by a .k-calendar__day button per day.
--muted fades the days that belong to the
neighboring months, aria-current="date" rings
today, and aria-selected="true" fills the selected
day with the accent — as everywhere in keel, the visual state is
the accessibility state. Honestly: this is a display
component. Marking dates is markup; picking dates is
input type="date" above, or your own JavaScript on
these buttons.
Markup
<div class="k-calendar">
<div class="k-calendar__head">
<button class="k-btn k-btn--icon k-btn--ghost" type="button" aria-label="Previous month">
<span class="k-icon k-icon--arrow-left" aria-hidden="true"></span>
</button>
<span>Quintilis 2026</span>
<button class="k-btn k-btn--icon k-btn--ghost" type="button" aria-label="Next month">
<span class="k-icon k-icon--arrow-right" aria-hidden="true"></span>
</button>
</div>
<div class="k-calendar__grid">
<abbr title="Monday">Mo</abbr> <!-- … seven weekday cells -->
<button class="k-calendar__day k-calendar__day--muted" type="button">29</button>
<button class="k-calendar__day" type="button">1</button>
<!-- … one button per day … -->
<button class="k-calendar__day" type="button" aria-current="date">14</button>
<button class="k-calendar__day" type="button" aria-selected="true">21</button>
</div>
</div>
List group
.k-list is a bordered stack of rows. Plain
li items get padding; an li whose only
child is a link becomes fully clickable with a hover surface —
no extra class, the a:only-child is the signal.
Mark the active row with aria-current on the
li and it gets the soft accent plus an inset accent
bar at the start edge. Add .k-list--flush to drop
the border, radius, and background — just the rows and their
separators, for sitting inside a card or sidebar.
- Getting started
- Components (you are here)
- Utilities
- A plain item — no link, no hover.
Markup
<ul class="k-list">
<li><a href="start.html">Getting started</a></li>
<li aria-current="true">Components (you are here)</li>
<li><a href="utilities.html">Utilities</a></li>
<li>A plain item — no link, no hover.</li>
</ul>
Accordion
.k-accordion wraps a group of native
details elements and draws them as one surface —
each loses its own box, a rule separates the rows. Give every
details the same name attribute and
the browser enforces one-open-at-a-time natively — opening one
closes the others, zero JavaScript. Leave the
name off and they open independently.
.k-accordion--flush drops the card frame — no
border, no background — leaving just the rows and the rules
between them, so the group sits inline with the page rather than
as its own surface. Good inside a card or a sidebar that already
supplies the box.
What is keel?
A zero-dependency CSS framework — one file, five layers, no build step.
Does it need JavaScript?
No. This accordion is native details; the
exclusivity comes from the name attribute.
Can I retheme it?
Yes — it reads the same tokens as everything else on this page.
Flush accordion
.k-accordion--flush — dividers only, no
frame or background.
Sits inline with the page
Useful when a surrounding card or sidebar already draws the box.
Markup
<div class="k-accordion">
<details name="faq" open>
<summary>What is keel?</summary>
<p>A zero-dependency CSS framework.</p>
</details>
<details name="faq">
<summary>Does it need JavaScript?</summary>
<p>No — the name attribute gives one-open-at-a-time natively.</p>
</details>
</div>
<!-- flush: dividers only, no card frame -->
<div class="k-accordion k-accordion--flush">
<details name="faq" open>
<summary>Flush accordion</summary>
<p>Sits inline with the page.</p>
</details>
</div>
Tables
Tables are styled by the base layer already — including a
caption, a tfoot, and tabular figures.
.k-table--striped adds alternating row backgrounds
for longer tables. .k-table--hover tints the row
under the pointer with the soft accent — useful when rows are
links or lead somewhere. .k-table--bordered rules
every cell, for dense reference tables. The variants combine.
For a table wider than a small screen, wrap it in
.k-table-wrap — the table keeps its natural width
and scrolls sideways inside the wrapper instead of breaking the
page.
| Class | Layer | Job |
|---|---|---|
| .k-section | layout | full-width band |
| .k-container | layout | per-band content width |
| .k-btn | components | button |
| .k-card | components | surface |
| Variant | Adds |
|---|---|
| .k-table--hover | accent-soft row hover (try it) |
| .k-table--bordered | a rule around every cell |
| Breakpoint | Min width | What moves | Where it's declared | Notes on the wrapping demo |
|---|---|---|---|---|
| sm | 640px | columns split | layout layer | this wide table scrolls sideways in .k-table-wrap |
| md | 820px | split & sidebar engage | layout layer | the page itself never scrolls horizontally |
| lg | 1080px | four columns | layout layer | narrow the window to see it |
Markup
<table class="k-table--striped">
<thead><tr><th>Class</th><th>Layer</th></tr></thead>
<tbody>
<tr><td>.k-section</td><td>layout</td></tr>
<tr><td>.k-btn</td><td>components</td></tr>
</tbody>
</table>
<!-- variants combine -->
<table class="k-table--hover k-table--bordered">…</table>
<!-- wide tables: wrap, don't squash -->
<div class="k-table-wrap">
<table class="k-table--striped">…</table>
</div>
Dense
.k-table--dense halves the cell padding — for
logs, admin screens, anywhere rows-per-screen beats breathing
room. It combines with the other variants like everything
else.
| Order | Item | Status |
|---|---|---|
| W-1041 | Oak shelf, sanded | finishing |
| W-1042 | Stool legs ×4 | glue-up |
| W-1043 | Drawer fronts | queued |
| W-1044 | Picture frame | delivered |
Markup
<table class="k-table--dense k-table--striped">
<thead><tr><th>Order</th><th>Item</th></tr></thead>
<tbody>
<tr><td>W-1041</td><td>Oak shelf, sanded</td></tr>
</tbody>
</table>
Sticky header
.k-table--sticky pins the header row while the
body scrolls under it. It only does something inside a scroll
container, so it's a pair: put the table in a
.k-table-wrap.k-table-wrap--scroll, which caps the
height and scrolls vertically. Scroll the log below — the
header stays.
| Day | Vessel | Berth |
|---|---|---|
| Mon | Grey Heron | 2 |
| Mon | Saltwind | 5 |
| Tue | Petrel | 1 |
| Tue | Low Tide | 4 |
| Wed | Cormorant | 3 |
| Wed | Grey Heron | 2 |
| Thu | Kittiwake | 6 |
| Thu | Saltwind | 5 |
| Fri | Petrel | 1 |
| Fri | Ballast Rose | 4 |
| Sat | Cormorant | 3 |
| Sat | Kittiwake | 6 |
Markup
<!-- the pair: --scroll gives the height cap,
--sticky pins the header inside it -->
<div class="k-table-wrap k-table-wrap--scroll">
<table class="k-table--sticky">
<thead><tr><th>Day</th><th>Vessel</th></tr></thead>
<tbody>…</tbody>
</table>
</div>
Numbers, caption, footer
Three things the base layer already handles: a
caption becomes the table's quiet title,
tfoot cells get a strong top rule and weight for a
totals row, and every table uses tabular figures — digits share
a width, so columns of numbers align without any class. The one
class here is .k-num, which right-aligns a numeric
column (put it on the th and its tds);
end-alignment plus tabular figures is what makes the totals
readable at a glance.
| Cargo | Crates | Weight (t) |
|---|---|---|
| Rope, coiled | 140 | 3.2 |
| Lamp oil | 36 | 7.9 |
| Timber, sawn | 88 | 21.4 |
| Total | 264 | 32.5 |
Markup
<table>
<caption>Berth 4 — outbound manifest</caption>
<thead>
<tr><th>Cargo</th><th class="k-num">Crates</th></tr>
</thead>
<tbody>
<tr><td>Rope, coiled</td><td class="k-num">140</td></tr>
</tbody>
<tfoot>
<tr><td>Total</td><td class="k-num">264</td></tr>
</tfoot>
</table>
Sortable affordance
Give a header aria-sort and put a
button inside it; keel styles the state — an
unsorted column shows a faint ↕, the sorted one an accent ↑ or ↓.
Because the state lives in aria-sort, the same
attribute that draws the arrow announces the sort order to
assistive tech. Honestly, though: that's the whole of keel's
contribution. Cycling the attribute and reordering the rows is
your JavaScript — the ten lines wired into this demo (click a
header) are printed below the markup.
| Ballast Rose | Timber, sawn | 88 |
| Cormorant | Lamp oil | 36 |
| Grey Heron | Rope, coiled | 140 |
| Petrel | Salt, bagged | 112 |
| Saltwind | Barrel staves | 64 |
Markup
<table id="cargo">
<thead>
<tr>
<th aria-sort="ascending"><button type="button">Vessel</button></th>
<th aria-sort="none"><button type="button">Cargo</button></th>
<th aria-sort="none"><button type="button">Crates</button></th>
</tr>
</thead>
<tbody>…</tbody>
</table>
<script>
// keel ships the styles; the sort is yours — for example:
document.querySelectorAll('#cargo th[aria-sort] > button').forEach(btn => {
btn.addEventListener('click', () => {
const th = btn.parentElement;
const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
th.parentElement.querySelectorAll('th[aria-sort]')
.forEach(h => h.setAttribute('aria-sort', 'none'));
th.setAttribute('aria-sort', dir);
const col = th.cellIndex, tbody = th.closest('table').tBodies[0];
[...tbody.rows]
.sort((a, b) => a.cells[col].textContent.localeCompare(
b.cells[col].textContent, undefined, { numeric: true })
* (dir === 'ascending' ? 1 : -1))
.forEach(r => tbody.appendChild(r));
});
});
</script>
Selected rows
A row with aria-selected="true" gets the soft
accent and an inset accent bar at its start edge — the same
visual-state-is-accessibility-state rule as the calendar above.
Keel styles the state only: the checkboxes or click handlers
that set the attribute are your JavaScript.
| Order | Item | Status |
|---|---|---|
| W-1041 | Oak shelf, sanded | finishing |
| W-1042 | Stool legs ×4 | glue-up |
| W-1043 | Drawer fronts | queued |
Markup
<tbody>
<tr><td>W-1041</td><td>Oak shelf, sanded</td></tr>
<tr aria-selected="true"><td>W-1042</td><td>Stool legs ×4</td></tr>
</tbody>
Responsive stacking
.k-table--stack is the other answer to narrow
screens: instead of scrolling sideways, below 640px each row
becomes a small card, with every value labeled by its column
name — resize the window to watch it happen. The mechanism is
a data-label attribute on every
td, which CSS prints in front of the value; the
header row hides visually but stays in the accessibility tree.
The honest trade-off: duplicating each column name into its
cells is the cost of doing this with zero JavaScript. Skip the
labels and the stacked cards are just bare values.
| Vessel | Berth | Cargo | Due |
|---|---|---|---|
| Grey Heron | 2 | Rope, coiled | Tue |
| Saltwind | 5 | Barrel staves | Wed |
| Petrel | 1 | Salt, bagged | Fri |
Markup
<table class="k-table--stack">
<thead><tr><th>Vessel</th><th>Berth</th></tr></thead>
<tbody>
<tr>
<td data-label="Vessel">Grey Heron</td>
<td data-label="Berth">2</td>
</tr>
</tbody>
</table>
And the boundary, plainly: filtering, pagination logic, and virtual scrolling are application logic. Keel provides the pagination component and the styles on this page; DataTables-style behavior belongs to your JavaScript or a library, and keel will happily style its output.
Stat
.k-stat__value and .k-stat__label
make a big number with a small caption. There is no wrapper
class — any element holds the pair. Add .k-stat--center
to center-align the stat, and a .k-stat__delta line
under the value for a change indicator —
--up colors it success green,
--down danger red.
Markup
<div class="k-thirds k-center">
<div>
<span class="k-stat__value">1</span>
<span class="k-stat__label">css file</span>
</div>
<!-- … -->
</div>
<!-- centered, with a change indicator -->
<div class="k-stat--center">
<span class="k-stat__value">1,284</span>
<span class="k-stat__label">visitors</span>
<span class="k-stat__delta k-stat__delta--up">↑ 12%</span>
</div>
Divider
.k-divider is a labeled rule — a line, a word,
a line. The usual job is "or" between two ways to do the same
thing (sign in with a password, or with a link). With no text
inside it's just two lines meeting; use a plain
hr for that.
--start and --end shift the label
toward one edge instead of centering it. And
.k-divider--y is the vertical case — a short inline
rule, one line-height tall, for separating inline items like two
links in a row.
Markup
<div class="k-divider">or</div>
<!-- label shifted to one edge -->
<div class="k-divider k-divider--start">Section</div>
<div class="k-divider k-divider--end">Section</div>
<!-- vertical rule between inline items -->
<a href="#">Edit</a>
<span class="k-divider--y"></span>
<a href="#">Delete</a>
Empty state
.k-empty is the nothing-here-yet box — a dashed
border, centered soft text, room to breathe. Say what's empty,
then offer the one action that fills it. It stands in for a
list, a table, a grid; swap it out when the first real item
arrives.
No projects yet
Projects you create will show up here.
Markup
<div class="k-empty">
<h4>No projects yet</h4>
<p>Projects you create will show up here.</p>
<button class="k-btn" type="button">New project</button>
</div>
Timeline
.k-timeline runs events down a line — each
li gets a dot and a rule to the next one; the line
stops at the last item. .k-timeline__time is the
small mono date above each event. Use a real
time element with a datetime attribute
so the date is machine-readable too.
Each dot can carry meaning: put .is-ok,
.is-warn, or .is-muted on a
li to color its dot green, amber, or grey — for a
done step, a warning, or one that's pending or skipped. For any
other color, set the --k-timeline-dot knob on the
item directly.
-
First prototype — tokens and base layers only.
-
Layout bands and the first components land.
-
The component layer roughly doubles — this page grows with it.
Markup
<ol class="k-timeline">
<li class="is-ok">
<time class="k-timeline__time" datetime="2026-05-02">May 2026</time>
<p>First prototype.</p>
</li>
<li>
<time class="k-timeline__time" datetime="2026-06-14">June 2026</time>
<p>Layout bands land.</p>
</li>
<li class="is-muted">
<time class="k-timeline__time" datetime="2026-07-10">July 2026</time>
<p>Pending step.</p>
</li>
</ol>
<!-- arbitrary dot color -->
<li style="--k-timeline-dot: var(--k-accent)">…</li>