Skip to content

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.

Quintilis 2026
MoTuWeThFrSaSu
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.

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.

ClassLayerJob
.k-sectionlayoutfull-width band
.k-containerlayoutper-band content width
.k-btncomponentsbutton
.k-cardcomponentssurface
VariantAdds
.k-table--hoveraccent-soft row hover (try it)
.k-table--bordereda rule around every cell
BreakpointMin widthWhat movesWhere it's declaredNotes on the wrapping demo
sm640pxcolumns splitlayout layerthis wide table scrolls sideways in .k-table-wrap
md820pxsplit & sidebar engagelayout layerthe page itself never scrolls horizontally
lg1080pxfour columnslayout layernarrow 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.

OrderItemStatus
W-1041Oak shelf, sandedfinishing
W-1042Stool legs ×4glue-up
W-1043Drawer frontsqueued
W-1044Picture framedelivered
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.

DayVesselBerth
MonGrey Heron2
MonSaltwind5
TuePetrel1
TueLow Tide4
WedCormorant3
WedGrey Heron2
ThuKittiwake6
ThuSaltwind5
FriPetrel1
FriBallast Rose4
SatCormorant3
SatKittiwake6
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.

Berth 4 — outbound manifest
CargoCratesWeight (t)
Rope, coiled1403.2
Lamp oil367.9
Timber, sawn8821.4
Total26432.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 RoseTimber, sawn88
CormorantLamp oil36
Grey HeronRope, coiled140
PetrelSalt, bagged112
SaltwindBarrel staves64
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.

OrderItemStatus
W-1041Oak shelf, sandedfinishing
W-1042Stool legs ×4glue-up
W-1043Drawer frontsqueued
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.

VesselBerthCargoDue
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.

1css file
0dependencies
~40components
1,284 visitors ↑ 12%
3.1% bounce ↓ 0.4%
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.

or
Section
Section

Edit Delete

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.

  1. First prototype — tokens and base layers only.

  2. Layout bands and the first components land.

  3. 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>