Skip to content
Sign in

Components

Reference

Import from @kasane/design/react, or write the classes yourself. Every class a component emits exists in the CSS, and the check runs on every commit.

Page

Covers the root, bands, column, and chrome

Kasane

Runs the theme script before paint and mounts the tip handler. One per page, in the root layout. No wrapper: the page ground is on body. motion none stops every transition; the spinner and skeleton keep moving.

<Kasane>
  <ToastProvider>{children}</ToastProvider>
</Kasane>
<!-- the head snippet stamps the theme before anything paints -->
<script src="/kasane/head.js"></script>
<link rel="stylesheet" href="/kasane/kasane.css" />

<!-- and the runtime at the end of the body wires every behaviour -->
<script src="/kasane/kasane.js"></script>
PropTypeNote
motion'full' | 'none'default 'full'
Band

A full-bleed band. Content goes in a Col.

<Band tone="deep" id="notes" labelledBy="notes-title">
  <Col>
    <Head title="Notes" id="notes-title" />
  </Col>
</Band>
<!-- the tones are hero, tone, deep and beni. --fill runs the ground to the bottom. -->
<section class="kb-band kb-band--deep" id="notes" aria-labelledby="notes-title">
  <div class="kb-col">
    <h2 class="kb-head__title" id="notes-title">Notes</h2>
  </div>
</section>
PropTypeNote
tone'hero' | 'tone' | 'deep' | 'beni'omit for the page ground
fillbooleantakes the viewport, for a whole screen
density'compact' | 'auto'auto is compact under 768px
asElementTypedefault 'section'
labelledBystringid of the heading inside
Col

The 1200px column.

<!-- centred, with the page gutter either side -->
<div class="kb-col">…</div>
PropTypeNote
classNamestring
Head with a mark

The same head carrying an identity. The mark is centred on the block, not on the title's baseline.

AT

Aoi Tanaka

Member

@aoi

<div class="kb-head kb-head--mark">
  <div class="kb-head__mark">…</div>
  <div class="kb-head__body">
    <div class="kb-head__line">
      <h2 class="kb-head__title">Aoi Tanaka</h2>
      <p class="kb-head__aside">Member</p>
    </div>
    <p class="kb-head__note">@aoi</p>
  </div>
</div>
Rail

A main column beside a fixed 260px track. at decides which side the track is on, and below 1024px the two stack in source order.

Main.

<div class="kb-rail">
  <div class="kb-rail__main">…</div>
  <aside class="kb-rail__side">…</aside>
</div>
PropTypeNote
sideReactNode
at'start' | 'end'default 'end'
Panel

A surface block. On the deep band in the light theme it takes the light theme.

A panel.

<div class="kb-panel">…</div>
PropTypeNote
asElementTypedefault 'div'
classNamestring
Main

The main landmark.

<main id="main">…</main>
PropTypeNote
idstringdefault 'main'
Bar

A floating pill holding the brand, section links, actions and children. actions hold their row while the links wrap on a phone. Setting variant to slim takes every control in it down to 32px, for a page carrying a link or two: pair it with a mark brand and the pill is as small as the ladder allows.

<Bar
  brand={{ name: 'Kasane', href: '/' }}
  items={items}
  link={Link}
  actions={
    <>
      <ButtonLink href="/sign-in" size="sm">Sign in</ButtonLink>
      <ButtonLink href="/sign-up" size="sm" variant="primary">Sign up</ButtonLink>
    </>
  }
>
  <ThemeSwitch />
</Bar>
<header class="kb-bar kb-bar--collapse">
  <a class="kb-bar__brand" href="/">
    <span class="kb-bar__name">Kasane</span>
    <span class="kb-bar__domain">kurobeni.jp</span>
  </a>
  <nav class="kb-bar__nav" aria-label="Primary">
    <a href="/works" aria-current="page">Works</a>
    <a href="/notes">Notes</a>
  </nav>
  <div class="kb-cluster kb-bar__actions">
    <a class="kb-btn kb-btn--sm kb-btn--primary" href="/sign-in">Sign in</a>
  </div>
  <!-- the runtime fills the icon and wires the click -->
  <button class="kb-btn kb-btn--icon" type="button" data-kb-theme aria-label="Theme"></button>
</header>

<!-- kb-bar--slim is the small control size throughout: a link or two and the theme switch -->
<header class="kb-bar kb-bar--slim">
  <a class="kb-bar__brand kb-bar__brand--mark" href="/" aria-label="Home">…</a>
  <nav class="kb-bar__nav" aria-label="Primary">…</nav>
</header>
PropTypeNote
brand{ name, domain?, href }
itemsNavItem[]{ href, label, current? }
actionsReactNodesign in, sign up, an account menu; pair with collapse
variant'slim'32px controls throughout
labelstringnav label, default 'Primary'
linkLinkComponent | 'a'next/link or a plain anchor
ThemeSwitch

One button that says the theme and flips it. Drop it anywhere: the bar styles it by its data-kb-theme attribute, not by a class.

<!-- the runtime fills the icon, the tip and the label -->
<button class="kb-btn kb-btn--icon" type="button" data-kb-theme aria-label="Theme"></button>

<!-- or the theme in words -->
<button class="kb-btn kb-btn--sm" type="button" data-kb-theme="text"></button>
PropTypeNote
classNamestringadded to the button
useTheme

[theme, setTheme] for a control of your own. The theme is the data-theme stamp, so setting it repaints the page and every other reader follows. Reports light before hydration. themeScript is the head snippet.

'use client';
import { useTheme } from '@kasane/design/react';

export function ThemeChoice() {
  const [theme, setTheme] = useTheme();
  return (
    <Tray label="Theme">
      <Button aria-pressed={theme === 'light'} onClick={() => setTheme('light')}>Light</Button>
      <Button aria-pressed={theme === 'dark'} onClick={() => setTheme('dark')}>Dark</Button>
    </Tray>
  );
}
<!-- there is no hook without React: the stamp on the document is the theme -->
<script>
  document.documentElement.dataset.theme = "dark";
  localStorage.setItem("kb-theme", "dark");
</script>
Invert

A block in the opposite theme of the page.

Healthy
<!-- the runtime stamps the opposite theme on it and keeps it opposite when the page flips -->
<div class="kb-panel" data-kb-invert>…</div>
PropTypeNote
classNamestring

Blocks

Covers the hero, tiles, collections, stats, and long-form blocks

Hero

The opening band. Renders its own Band. actions sit under the intro, in the second column.

<Hero
  title="Kasane"
  intro="Tokens, CSS and a React layer."
  actions={
    <>
      <ButtonLink href="/install" variant="primary">Install</ButtonLink>
      <ButtonLink href="/components">Components</ButtonLink>
    </>
  }
/>
<section class="kb-band kb-band--hero" aria-labelledby="hero-title">
  <div class="kb-col kb-hero">
    <div>
      <h1 class="kb-hero__title" id="hero-title">Kasane</h1>
      <p class="kb-hero__name">Design system</p>
    </div>
    <div class="kb-hero__aside">
      <p class="kb-hero__intro">Tokens, CSS and a React layer.</p>
      <div class="kb-cluster">
        <a class="kb-btn kb-btn--primary" href="/install">Install</a>
      </div>
    </div>
  </div>
</section>
PropTypeNote
titleReactNode
nameReactNode
introReactNode
actionsReactNodebuttons or links
idstringheading id, default 'hero-title'
Tile

A whole-tile link, inside Tiles.

<div class="kb-tiles">
  <a class="kb-tile" href="/storage">
    <p class="kb-tile__meta"><span>Tool</span><span class="kb-num">2026</span></p>
    <h3 class="kb-tile__title">storage</h3>
    <p class="kb-tile__desc">Moon phase and tide tracker.</p>
  </a>
</div>
PropTypeNote
hrefstring
kindReactNode
yearReactNode
titleReactNode
levelHeadingLeveldefault 3
smallboolean
linkLinkComponent | 'a'next/link or a plain anchor
childrenReactNodethe description
Collection, Item

A list of things of one kind. Tile is a link-card with the title at the foot; an item leads with its title and carries whatever qualifies it underneath. marks is the line of words under the title, aside the figures at the far end of the title row. empty stands in for a list with nothing in it.

kasane

142 starsupdated 2 days ago
PublicTypeScript

The design system for kurobeni.jp.

storage

8 starsupdated 5 weeks ago
PrivateGo

Moon phase and tide tracker.

<!-- the item is the unit and the page is its container: no panel around the list, and
     space between the items, never a rule -->
<div class="kb-collection">
  <article class="kb-item">
    <div class="kb-item__row">
      <h3 class="kb-item__title"><a href="/kasane">kasane</a></h3>
      <div class="kb-cluster kb-cluster--wide kb-muted kb-small">
        <span class="kb-num">142 stars</span>
        <span>updated 2 days ago</span>
      </div>
    </div>
    <span class="kb-cluster kb-cluster--tight">
      <span class="kb-status kb-status--ok">Open</span>
    </span>
    <p class="kb-item__text">The design system for kurobeni.jp.</p>
  </article>
</div>

<!-- kb-collection--tile stands them two abreast, one column under 768px -->
<div class="kb-collection kb-collection--tile">…</div>
PropTypeNote
Collection density'row' | 'tile'default 'row'
Collection emptyReactNodeshown as an Empty when there are no items
titleReactNode
hrefstringmakes the title a link
levelHeadingLeveldefault 3
marksReactNode
asideReactNode
linkLinkComponent | 'a'next/link or a plain anchor
childrenReactNodeItemText is the description
Stat

A big number, inside Stats. direction draws the arrow, sentiment colours it. size raises the figure to 40 or 72; compact steps each down a rung. href or onSelect makes the whole tile a control.

3.76MRequestsUp 12.4%
1Failed deploysUp 1 since yesterday
99.97%Uptime30d
418ppmCarbon dioxide
21.4°CTemperature
418ppmCarbon dioxide+23 in 15 min
21.4°CTemperatureSteady
47%HumiditySteady
102VOC index+14
42Air quality
<div class="kb-stats">
  <div class="kb-stat">
    <span class="kb-stat__value">3.76M</span>
    <span class="kb-stat__label">Requests</span>
    <span class="kb-stat__delta kb-is-up">12.4%</span>
  </div>
</div>

<!-- a unit, and a bar under the label. The bar is decoration: the value is already read out. -->
<div class="kb-stat kb-stat--lg">
  <span class="kb-stat__value">418<span class="kb-stat__unit">ppm</span></span>
  <span class="kb-stat__label">Carbon dioxide</span>
  <span class="kb-stat__bar" aria-hidden="true" style="--kb-progress-value: 62%"><span></span></span>
</div>

<!-- flush, for a page with no panels: kb-stats--flush on the grid, kb-stat--flush on one alone -->
<div class="kb-stats kb-stats--flush">
  <div class="kb-stat">
    <span class="kb-stat__value">47<span class="kb-stat__unit">%</span></span>
    <span class="kb-stat__label">Humidity</span>
  </div>
</div>
PropTypeNote
valueReactNode
unitReactNodeafter the value, in secondary ink
labelReactNode
deltaReactNode
direction'up' | 'down'
sentiment'good' | 'bad' | 'neutral'default 'neutral'
size'lg' | 'xl'40px or 72px, default 32
barnumbera percentage, drawn under the label
hrefstringmakes the whole stat a link
onSelect() => voidmakes the whole stat a button
linkLinkComponent | 'a'next/link or a plain anchor
Stats flushbooleandrops the tile ground and inset from every stat inside
Cta

The beni band. Renders its own Band.

<section class="kb-band kb-band--beni" aria-labelledby="cta-title">
  <div class="kb-col kb-cta">
    <div>
      <h2 class="kb-head__title" id="cta-title">Say hello</h2>
      <p class="kb-cta__text">Tell me what you are making.</p>
    </div>
    <div class="kb-cluster kb-cta__actions">
      <a class="kb-btn kb-btn--primary kb-btn--lg" href="/contact">Email</a>
    </div>
  </div>
</section>
PropTypeNote
titleReactNode
textReactNode
actionsReactNode
idstringheading id, default 'cta-title'
Prose

Long-form headings, lists, figures, tables, and code get their margins back. plain drops the panel, for prose on any ground. tight steps the block down a rung at every rung: the editorial inset is sized for a README, and a one-line comment inside it is mostly air. The spacing rules are direct-child, so markup rendered elsewhere goes in through html and lands on the block itself; a wrapper around it would take the rules instead. Article puts a Prose and a Toc side by side. A bare code and a bare pre inside it are styled too. document is for a file someone else wrote: it drops the measure, since every other file viewer runs the width it is given, and a > quote reads as a quote instead of a pull quote. A renderer marks an admonition with data-kb-alert on the blockquote and puts the label in its first paragraph.

On margins

The unit is 8px.

Every gap, padding and control height is a multiple of it. --kb-space-8

Rules

  • Controls on one row share one height.
  • Muted text clears 4.5:1 or becomes ink.

Rendered elsewhere

This block came back from a Markdown renderer as a string of HTML. It is set on the prose element itself, so .kb-prose > * + * reaches these children.

  • Headings, lists and code keep their spacing.
  • A wrapper around them would not.

Looks right to me. The gutter lands where blame needs it, and it stays put on a wide file.

One thing: the ceiling should be shared across both charts or the second one reads as bigger than it is.

<!-- long-form: the block sets the measure, so the elements inside need no classes -->
<div class="kb-panel kb-prose">
  <h2 class="kb-prose__title">On margins</h2>
  <p class="kb-lede">The opening line, larger than the body.</p>
  <p>Ordinary paragraphs, lists and blockquotes all take their type from the block.</p>
</div>

<!-- kb-prose--plain drops the panel and its inset, for long-form on the page ground -->
<div class="kb-prose kb-prose--plain">…</div>

<!-- kb-prose--tight is a few lines, not a document: a comment, a release note, a description -->
<div class="kb-panel kb-prose kb-prose--tight">…</div>

<!-- kb-prose--doc is a file someone wrote: no measure, and a quote is a quote -->
<div class="kb-prose kb-prose--doc">
  <blockquote data-kb-alert="note">
    <p>Note</p>
    <p>The renderer names the kind. Five are known: note, tip, important, warning, caution.</p>
  </blockquote>
</div>
PropTypeNote
asElementTypedefault 'article'
plainbooleanon the page ground instead of a panel
documentbooleana rendered file: no measure, and a quote is a quote
tightbooleana few lines, not a document
htmlstringmarkup rendered elsewhere, set on the block
classNamestring
Toc

On this page, as a panel.

<div class="kb-article">
  <div class="kb-prose">…</div>
  <!-- the runtime marks the entry for the section under the reading line -->
  <nav class="kb-toc" aria-label="On this page">
    <span class="kb-caps">On this page</span>
    <ul class="kb-nav">
      <li><a class="kb-nav__item" href="#margins">Margins</a></li>
    </ul>
  </nav>
</div>
PropTypeNote
items{ href, label }[]
labelstringdefault 'On this page'
linkLinkComponent | 'a'next/link or a plain anchor
Sparkline

A shape the size of a line of text: no axes, no ticks. The stroke is currentColor. Drawn on the server, so no script ships. A flat run is a straight line; one value is a point.

Line
Area
Flat, and a single value
<!-- there is no runtime equivalent: the shape is drawn from the data, so the
     page that has the numbers draws it. -->
<svg class="kb-spark" viewBox="0 0 120 32" width="120" height="32" role="img" aria-label="PM 2.5, 24 hours, 8 to 42">
  <path class="kb-spark__line" d="M1.5 24 L60 8 L118.5 3" vector-effect="non-scaling-stroke" />
  <circle class="kb-spark__point" cx="118.5" cy="3" r="2" />
</svg>
PropTypeNote
valuesnumber[]
labelstringnames the shape and its range
widthnumberdefault 120
heightnumberdefault 32
areabooleanfills under the line
pointbooleanmarks the last value, default true
Series

Eight hues for things that differ from each other: a flow in a commit graph, a line in a legend. Eight tokens, --kb-series-1 to --kb-series-8, spent wherever the colour goes. The order is the contract, so the same thing gets the same colour every time, and it runs by separation: the first two are the furthest apart, and any prefix is the best set of its size. Lightness carries as much of the separation as hue: it is the channel that survives when the red-green axis collapses. Beni keeps the chroma; the series keep the lightness. They are measured at the 3:1 floor against the page and a panel in both themes, and every pair is held apart under normal vision and the three dichromacies, so a swatch may carry the colour but the label beside it stays ink. In dark there is a ceiling as well, 7.5:1: the same token draws a hairline and a 96px fill, and a near-white fill is the brightest thing on a dark page. In light the loud end is near-ink, which is what the page is already written in, so it needs no ceiling.

Eight flows down a page, in order. One colour a flow, so a branch can be followed to where it started.

<!-- the tokens go straight into the drawing, since the shape belongs to the page -->
<svg viewBox="0 0 68 72" width="68" height="72" aria-hidden="true" fill="none" stroke-width="2">
  <path d="M6 0 v72" stroke="var(--kb-series-1)" />
  <path d="M18 0 v24 l12 12 v36" stroke="var(--kb-series-2)" />
  <path d="M30 0 v20" stroke="var(--kb-series-3)" />
</svg>
PropTypeNote
--kb-series-1ochre
--kb-series-2teal
--kb-series-3pine
--kb-series-4mulberry
--kb-series-5plum
--kb-series-6indigo
--kb-series-7moss
--kb-series-8olive
Columns

Values over a time axis, one column a slot. Every column is drawn against one ceiling, so a slot is comparable to the slot before it: a Scale's parts are shares of their own track and would draw every week the same length, and a Sparkline carries the trend but not the magnitude. Two series: added against removed, opened against closed. They pair inside each slot in the order given. The palette is the series tokens, so the first two are the furthest apart. A column worth nothing draws nothing; anything else keeps a head the eye can find. A column stops widening at 24px and the plot keeps to the left of what is left over, since two full-width columns read as blocks and not as a chart.

12 weeks agoToday
  • Added
  • Removed
AprilSeptember
  • Opened
  • Closed
AugustSeptember
  • Opened
  • Closed
<!-- there is no runtime equivalent: the heights come from the data, so the page
     that has the numbers sets them. --kb-column is the column's share of the ceiling.
     --kb-columns-slot caps a slot at 24px a column and --kb-columns-span caps the plot at the
     slots it holds, so two slots do not stretch to the panel. -->
<div class="kb-columns" style="--kb-columns-slot: 49px; --kb-columns-span: 314px">
  <div class="kb-columns__plot" role="img" aria-label="Lines a week, 6 weeks">
    <div class="kb-columns__slot" title="Week of 4 August: 820 added, 210 removed">
      <span class="kb-columns__bar" style="--kb-column: 69%"></span>
      <span class="kb-columns__bar" style="--kb-column: 18%"></span>
    </div>
    <div class="kb-columns__slot" title="Week of 11 August: 140 added, 90 removed">
      <span class="kb-columns__bar" style="--kb-column: 12%"></span>
      <span class="kb-columns__bar" style="--kb-column: 8%"></span>
    </div>
  </div>
  <div class="kb-columns__ticks"><span>6 weeks ago</span><span>Today</span></div>
  <ul class="kb-key">
    <li><span class="kb-key__swatch"></span>Added</li>
    <li><span class="kb-key__swatch"></span>Removed</li>
  </ul>
</div>
PropTypeNote
seriesColumnSeries[]up to eight; the palette starts again at nine
ColumnSeries namestringthe name in the key
ColumnSeries valuesnumber[]one per slot, oldest first
labelstringwhat the columns measure
ticksReactNode[]the ends name the span
titlesstring[]one per slot, shown on hover
maxnumberthe shared ceiling; the largest value when omitted
heightnumberthe plot in px, default 96
Key

What each colour in a plot stands for. Columns draws one from the series names; a Scale draws one from its parts when legend is set, stepping down the ink ladder the parts step down. The swatch carries the colour and the name carries the meaning, so neither rests on the other.

  • Added
  • Removed
  • Unchanged
  • TypeScript 44.6%
  • HTML 23.3%
  • CSS 20.9%
<ul class="kb-key">
  <li><span class="kb-key__swatch"></span>Added</li>
  <li><span class="kb-key__swatch"></span>Removed</li>
</ul>

<!-- the ink ladder a scale's parts step down, in the same order -->
<ul class="kb-key kb-key--ink">
  <li><span class="kb-key__swatch"></span>TypeScript 44.6%</li>
  <li><span class="kb-key__swatch"></span>HTML 23.3%</li>
</ul>
PropTypeNote
itemsReactNode[]in the order the plot drew them
inkbooleanthe ink ladder instead of the series palette
Calendar

A year of days, inked by value. A column is a week and a row a weekday. days may be sparse and in any order: the grid fills the gaps, buckets against the loudest day, and names a month at the week it starts. Below its own width the year scrolls; it never wraps.

LessMore
<figure class="kb-calendar">
  <div class="kb-calendar__scroll">
    <div class="kb-calendar__months" aria-hidden="true">
      <span class="kb-calendar__month">Sept</span>…
    </div>
    <div class="kb-calendar__grid" role="img" aria-label="Contributions: 1204">
      <span class="kb-calendar__day" data-kb-level="0" title="0 on 7 Sept 2026"></span>
      <span class="kb-calendar__day" data-kb-level="3" title="9 on 8 Sept 2026"></span>…
    </div>
  </div>
  <figcaption class="kb-calendar__key">Less … More</figcaption>
</figure>
PropTypeNote
days{ date: string; value: number }[]
labelstringnames the grid for a screen reader
weeksnumberdefault 53
legend[ReactNode, ReactNode]the key under the grid, empty end first. Default ['Less', 'More']
Listing

A header strip and the rows it belongs to, in one box. The box clips, so neither the strip nor the last row squares off a corner. Rows are flush, so a rule tells them apart: where items want room instead, that is Collection. The body takes rows or a table.

tsuki merged the release branch2b70b37 · 5 hours ago
Commits on 11 Sept 2026
the tray makes its own groundtsuki committed 5 hours ago8f2b16d
a head can carry a marktsuki committed 7 hours ago59e8abd
<div class="kb-listing">
  <div class="kb-listing__head">
    <span class="kb-listing__line">tsuki merged the release branch</span>
    <span class="kb-listing__meta">2b70b37 · 5 hours ago</span>
  </div>
  <div class="kb-listing__group">Commits on 11 Sept 2026</div>
  <div class="kb-listing__row">
    <span class="kb-listing__body">
      <span class="kb-listing__line"><a href="#">the tray makes its own ground</a></span>
      <span class="kb-listing__note">tsuki committed 5 hours ago</span>
    </span>
    <span class="kb-listing__meta">8f2b16d</span>
  </div>
</div>
PropTypeNote
Listing headReactNodethe strip above the rows
ListingHead figureReactNode
ListingHead metaReactNodetrailing, in secondary ink
ListingHead actionReactNode
ListingRow figureReactNode
ListingRow titleReactNodethe primary line. It takes the width and truncates
ListingRow hrefstring
ListingRow linkAnchordefault 'a'
ListingRow noteReactNodea second line, in secondary ink
ListingRow metaReactNode
ListingRow actionReactNode
ListingGroup childrenReactNodewhat the run of rows under it has in common
ListingTable childrenReactNodea table body, for columns that line up
Log

Time and severity in a gutter, request and message on one wrapping line. For entries that share no column set, where a table leaves half of every row empty. Every line is its own grid, so the gutter is measured once: off the longest tag in the stream, or from gutter. logKind() reads the level, then the status class.

  1. Today
  2. 502GET /api/hosts upstream timed out (110: Connection timed out) while reading response header from upstream
  3. 200GET /api/hosts
  4. 404GET /.env
  5. warncould not build optimal proxy_headers_hash, you should increase either proxy_headers_hash_max_size: 512 or proxy_headers_hash_bucket_size: 64
  6. 200GET /hosts
  1. 9 September
  2. 4f9c1ae2b0a scale holds shares of a whole jacob
  3. 7b4b8c3d19a tooltip takes the edge jacob
<!-- the gutter is set on the list: --kb-log-tag is the tag column's width -->
<ol class="kb-log" style="--kb-log-tag: 3ch">
  <li class="kb-log__day">Today</li>
  <li class="kb-log__line kb-log__line--err">
    <time class="kb-log__at">21:47:03</time>
    <span class="kb-log__tag">502</span>
    <span class="kb-log__body"><span class="kb-log__verb">GET</span> /api/hosts <span class="kb-log__msg">upstream timed out (110: Connection timed out)</span></span>
  </li>
</ol>
PropTypeNote
Log labelstringnames the list
Log gutterstringthe tag column width; read off the tags when omitted
LogLine atReactNodethe time, already formatted
LogLine dateTimestringmachine-readable, when at is not
LogLine tagReactNodethe status or the level, in the gutter
LogLine kind'warn' | 'err'colours the tag and nothing else
LogLine methodReactNode
LogLine pathReactNode
LogLine childrenReactNodethe message, in secondary ink
LogDay childrenReactNodea date, as a line in the stream
Source

A file read line by line. The gutter and the code are two columns of one block, so the block sets the type: the reset shrinks a bare code element, and a gutter sized in one font runs out of step with code sized in another. anchor gives every line a URL. Pass lines where something else has already highlighted them. gutter opens a second column for an annotation, one entry a line, outside the column that scrolls: blame is the case, and a blank entry leaves a line quiet. gutterWidth is what an entry gets, not what the track measures, so the default 10ch holds a ten-character sha.

.kb-log { --kb-log-tag: 2.25rem; } .kb-log__line { display: grid; grid-template-columns: auto var(--kb-log-tag) minmax(0, 1fr); gap: var(--kb-space-12); }
1d0d0c65ae7b4b8c3
.kb-log { --kb-log-tag: 2.25rem; } .kb-log__line { display: grid; grid-template-columns: auto var(--kb-log-tag) minmax(0, 1fr); gap: var(--kb-space-12); }
<div class="kb-source">
  <!-- each number is a link to its own line, so a line has a URL -->
  <div class="kb-source__nums">
    <a id="L1" href="#L1">1</a>
    <a id="L2" href="#L2">2</a>
    <a id="L3" href="#L3">3</a>
  </div>
  <div class="kb-source__code" tabindex="0" role="region" aria-label="log.css"><code>.kb-log__line {
  grid-template-columns: auto var(--kb-log-tag) minmax(0, 1fr);
}</code></div>
</div>

<!-- an annotated listing: the gutter is a column of the block, so it stays put while the code
     scrolls sideways. --kb-source-gutter is its width. -->
<div class="kb-source kb-source--gutter" style="--kb-source-gutter: 10ch">
  <div class="kb-source__gutter">
    <span><a href="/commit/1d0d0c65ae">1d0d0c65ae</a></span>
    <span></span>
  </div>
  <div class="kb-source__nums"><span>1</span><span>2</span></div>
  <div class="kb-source__code" tabindex="0" role="region" aria-label="Blame of log.css"><code>.kb-log {
  --kb-log-tag: 2.25rem;</code></div>
</div>
PropTypeNote
codestringone line per newline
linesReactNode[]the lines already marked up
startnumberthe first line number, default 1
anchorstringmakes each number a link, at #L1
gutterReactNode[]one entry a line, before the numbers
gutterWidthstringan entry's width, default 10ch
labelstringnames the block
Diff

A unified diff, one surface a file. Rows are add, del, ctx or hunk. The tint says which side a row is on and the sign in the gutter says it again, so nothing rests on colour alone. note takes the place of the rows for a binary file or a truncated one.

src/css/components/log.css
Changed+1−1
@@ -12,7 +12,7 @@
1212.kb-log__line {
13− grid-template-columns: auto 2.25rem minmax(0, 1fr);
13+ grid-template-columns: auto var(--kb-log-tag) minmax(0, 1fr);
1414 gap: var(--kb-space-12);

412 more lines not shown.

docs/screens/stream.png → docs/screens/log.pngRenamed

Binary file.

<div class="kb-diff">
  <section class="kb-diff__file">
    <div class="kb-diff__head">
      <span class="kb-diff__path">src/css/components/log.css</span>
      <span class="kb-cluster kb-cluster--tight">
        <span class="kb-status kb-status--ok">+1</span>
        <span class="kb-status kb-status--err">−1</span>
      </span>
    </div>
    <div class="kb-diff__body" tabindex="0" role="region" aria-label="src/css/components/log.css">
      <table>
        <tbody>
          <tr class="kb-diff__row--hunk">
            <td class="kb-diff__text" colspan="4">@@ -12,7 +12,7 @@</td>
          </tr>
          <tr>
            <td class="kb-diff__num">12</td>
            <td class="kb-diff__num">12</td>
            <td class="kb-diff__sign"></td>
            <td class="kb-diff__text">.kb-log__line {</td>
          </tr>
          <!-- the sign is in the markup: a row must not read as removed by its tint alone -->
          <tr class="kb-diff__row--del">
            <td class="kb-diff__num">13</td>
            <td class="kb-diff__num"></td>
            <td class="kb-diff__sign">−</td>
            <td class="kb-diff__text">  grid-template-columns: auto 2.25rem minmax(0, 1fr);</td>
          </tr>
          <tr class="kb-diff__row--add">
            <td class="kb-diff__num"></td>
            <td class="kb-diff__num">13</td>
            <td class="kb-diff__sign">+</td>
            <td class="kb-diff__text">  grid-template-columns: auto var(--kb-log-tag) minmax(0, 1fr);</td>
          </tr>
        </tbody>
      </table>
    </div>
    <p class="kb-diff__note">412 more lines not shown.</p>
  </section>
</div>
PropTypeNote
DiffFile pathReactNode
DiffFile fromReactNodethe old path, where the file moved
DiffFile actionsReactNodethe counts and the state
DiffFile noteReactNodestands in for rows that are not drawn
DiffRow kind'add' | 'del' | 'ctx' | 'hunk'default 'ctx'
DiffRow beforeReactNodethe line number on the old side
DiffRow afterReactNodeand on the new side
Scale

A value on a named range. Pass runs instead of a value and the slot holds a history: one cell a period, oldest first. Pass parts and it holds shares of a whole, and legend names them under the track.

0 Good100 Moderate200 Poor300+
0620 GB used1 TB
  • TypeScript 44.6%
  • HTML 23.3%
  • CSS 20.9%
  • JavaScript 11.2%
90 days ago99.98% uptimeToday
<div class="kb-scale" style="--kb-scale-at: 14%">
  <div class="kb-scale__track kb-scale__track--zones" role="img" aria-label="Air quality: 42 of 0 to 300">
    <span class="kb-scale__marker"></span>
  </div>
  <div class="kb-scale__ticks">
    <span>0 Good</span><span>100 Moderate</span><span>200 Poor</span><span>300+</span>
  </div>
</div>

<!-- shares of a whole: one part per share, its width given per part -->
<div class="kb-scale kb-scale--parts">
  <div class="kb-scale__track" role="img" aria-label="Languages">
    <span class="kb-scale__part" style="--kb-scale-part: 44.6%"></span>
    <span class="kb-scale__part" style="--kb-scale-part: 23.3%"></span>
  </div>
  <ul class="kb-key kb-key--ink">
    <li><span class="kb-key__swatch"></span>TypeScript 44.6%</li>
    <li><span class="kb-key__swatch"></span>HTML 23.3%</li>
  </ul>
</div>
PropTypeNote
valuenumberclamped to the range
minnumberdefault 0
maxnumberdefault 100
zonesbooleanthe range carries the verdict, so the marker takes no colour
fillbooleanfills from the start instead of marking a point
runsScaleRun[]replaces the track with a history
partsScalePart[]shares of a whole, stepping down one ink ladder
legendbooleannames the parts under the track, as a Key
ticksReactNode[]the ends name the range
labelstringwhat the range measures
Facts

Key and value in columns, one column a subject. A table across a full band is mostly empty.

Dew point
9.4 °C
Heat index
20.8 °C
Absolute humidity
8.1 g/m³
Dose rate, peak 24 h
0.19 µSv/h
Total dose, 24 h
2.84 µSv
Annual estimate
0.96 mSv
Sunrise
06:52
Sunset
19:04
Daylight left
7h 42m
<dl class="kb-facts">
  <div class="kb-facts__group">
    <dt>Dew point</dt><dd>9.4 &deg;C</dd>
    <dt>Heat index</dt><dd>20.8 &deg;C</dd>
  </div>
  <div class="kb-facts__group">
    <dt>Sunrise</dt><dd>06:52</dd>
    <dt>Sunset</dt><dd>19:04</dd>
  </div>
</dl>
PropTypeNote
childrenFactGroup elements
Callout

A note in a box. err is an alert.

NeutralMaintenance on Sunday 02:00 JST.
NoticeA new version is available.
SavedChanges are live.
Certificate expires in 6 daysRenewal is automatic.
Deploy failedHealth check timed out after 30 s.
<p class="kb-callout kb-callout--warn">
  <strong>Certificate expires in 6 days</strong>
  Renew before Friday.
</p>
PropTypeNote
kind'neutral' | 'notice' | 'ok' | 'warn' | 'err'default 'neutral'
titleReactNode
Disclosure, Accordion

A details block: the browser owns the open state, the keyboard and find-in-page, so no JS ships. One name across a run keeps one open. Accordion spaces them.

Where does the CSS come from?
One stylesheet at @kasane/design/css, built from the tokens. Import it once in the root layout.
Can I use it without React?
Yes. The classes are the system; @kasane/design/js wires the parts that need behaviour.
How do I change a colour?
Edit the token, not the CSS. Everything downstream follows, and the contrast report re-runs on build.
<div class="kb-accordion">
  <!-- one name, so the browser closes the others -->
  <details class="kb-disclosure" name="faq" open>
    <summary class="kb-disclosure__summary">Where does the CSS come from?</summary>
    <div class="kb-disclosure__body">One stylesheet, built from the tokens.</div>
  </details>
  <details class="kb-disclosure" name="faq">
    <summary class="kb-disclosure__summary">Can I use it without React?</summary>
    <div class="kb-disclosure__body">Yes. The classes are the system.</div>
  </details>
</div>
PropTypeNote
summaryReactNodethe always-visible line
namestringshared name closes the others
openbooleanopen on first paint
childrenReactNodethe panel
Empty

An empty state.

No deployments yet. Push to main to create one.

<div class="kb-empty">
  <p>No deployments yet.</p>
</div>
Sheet

Rows of surface, one per family, each with a caps label. SheetRow takes label and id.

<div class="kb-sheet">
  <div class="kb-sheet__row" id="buttons">
    <span class="kb-caps">Buttons</span>
    …
  </div>
</div>

Controls

Covers buttons, words, identity, and layout

Button

Passes every button attribute through. ButtonLink is the same look on an anchor. LinkButton is the link look; href renders an anchor.

Link as button
<button class="kb-btn kb-btn--primary" type="button">Save</button>
<button class="kb-btn" type="button">Cancel</button>
<button class="kb-btn kb-btn--danger" type="button">Delete</button>
<a class="kb-btn kb-btn--sm" href="/notes">Link as button</a>
<button class="kb-btn-link" type="button">A link in running text</button>
PropTypeNote
variant'secondary' | 'primary' | 'accent' | 'danger'default 'secondary'
size'sm' | 'md' | 'lg'32, 40, 48px
iconbooleana square button holding one icon, needs aria-label
tipstringtooltip on hover and focus
pushbooleanmargin-left: auto
Tray

A pill holding a segmented set. Buttons inside carry aria-pressed, links aria-current. The tray makes its own ground, so it reads on the page as well as on a panel; --kb-tray-bg overrides it.

<!-- one pill holding a segmented set. Selection is aria-pressed, aria-current on links, aria-selected on tabs. -->
<div class="kb-tray" role="group" aria-label="Range">
  <button class="kb-btn" type="button" aria-pressed="false">24h</button>
  <button class="kb-btn" type="button" aria-pressed="true">7d</button>
</div>
<div class="kb-tray" role="group" aria-label="Section">
  <a class="kb-btn" href="#overview" aria-current="page">Overview</a>
  <a class="kb-btn" href="#activity">Activity</a>
</div>
PropTypeNote
labelstring
role'group' | 'tablist'default 'group'
Status

A word in its colour.

HealthyDegradedFailedBuilding
<span class="kb-status kb-status--ok">Healthy</span>
<span class="kb-status kb-status--warn">Degraded</span>
<span class="kb-status kb-status--err">Failed</span>
PropTypeNote
kind'neutral' | 'ok' | 'warn' | 'err'default 'neutral'
Text, VisuallyHidden

The text roles, so a page never carries a class. size takes one of the nine type sizes; muted is secondary ink, small 14px, caps the tracked label, num lines figures up. VisuallyHidden is read, not drawn.

Twenty-four, the section title size

Secondary ink, for a line supporting the one above it

A tracked label

1,482 runs

<p class="kb-muted kb-small">Secondary, small.</p>

<!-- read out, never drawn -->
<span class="kb-vh">Up</span>
PropTypeNote
asElementTypedefault 'p'
size12 to 72one of the nine sizes
muted, small, caps, numboolean
Figure, FigureSwatch

An image, video or chart with its caption. The image goes in as children, so next/image, img and svg all work. FigureSwatch is the placeholder.

Fig. 1. Deploy duration, last thirty days.
<figure class="kb-figure">
  <img src="/chart.png" alt="Requests over seven days" />
  <figcaption>Requests, 7 days.</figcaption>
</figure>
PropTypeNote
captionReactNode
labelstringFigureSwatch, names the placeholder
Kbd, Code, Pre

Keys, inline code, a code block. The token colours are the seven code roles, wired to Shiki's css-variables theme, highlight.js and Chroma, so a fence highlighted by any of the three takes the same ink.

CtrlK--kb-space-8: 8px
.card {
  border-radius: var(--kb-radius-block);
}
<span class="kb-cluster kb-cluster--tight">
  <kbd class="kb-kbd">Ctrl</kbd><kbd class="kb-kbd">K</kbd>
</span>

<code class="kb-code">--unit: 8px</code>

<pre class="kb-pre"><code>pnpm add @kasane/design</code></pre>
Avatar

Initials in a circle, or a picture over them. src is the picture; the initials stay behind it, so a request that never arrives leaves a mark, not a hole. With a name it is an image; without one it is hidden, since the control around it carries the name. AvatarStack overlaps them.

JBKBKBKBJBTS+3
<span class="kb-avatar">JB</span>
<span class="kb-avatar kb-avatar--lg" role="img" aria-label="Jacob">JB</span>

<!-- the picture covers the initials. Its alt is empty, so a request that fails leaves them. -->
<span class="kb-avatar" role="img" aria-label="Jacob">
  JB<img class="kb-avatar__img" src="/avatars/jacob" alt="" loading="lazy" />
</span>

<span class="kb-avatar-stack">
  <span class="kb-avatar">JB</span><span class="kb-avatar">TS</span><span class="kb-avatar">+3</span>
</span>
PropTypeNote
initialsReactNode
srcstringthe picture; initials are the fallback
namestring
size'md' | 'lg'
Crumbs

Breadcrumb. The last entry has no href and is the page.

  1. auth
  2. Deployments
  3. storage
<!-- the last crumb is where you are, so it is not a link -->
<ol class="kb-crumbs">
  <li><a href="/auth">auth</a></li>
  <li><span aria-current="page">storage</span></li>
</ol>
PropTypeNote
trail{ href?, label }[]
linkLinkComponent | 'a'next/link or a plain anchor
Pages

Page numbers. A null entry is a gap.

<nav class="kb-pages" aria-label="Pagination">
  <a href="?p=1" aria-label="Previous page">‹</a>
  <a href="?p=1" aria-current="page">1</a>
  <a href="?p=2">2</a>
  <span>…</span>
  <a href="?p=12">12</a>
  <a href="?p=2" aria-label="Next page">›</a>
</nav>
PropTypeNote
pages({ href, label, current? } | null)[]
prevstring
nextstring
labelstringdefault 'Pagination'
pushboolean
linkLinkComponent | 'a'next/link or a plain anchor
Progress

A progressbar. Skeleton is one shimmering line.

Build, 25%
Upload, 70%
Loading
<progress class="kb-progress" value="25" max="100" aria-label="Build"></progress>
<div class="kb-skeleton" style="width: 80%"></div>
PropTypeNote
valuenumber
maxnumberdefault 100
labelstring
Spinner

A ring that turns while something loads. The label is read, not drawn. Takes the current ink; motion none does not stop it.

Loadingbeside anything
<span class="kb-spin" aria-hidden="true"></span>
<span class="kb-vh" role="status">Loading</span>
PropTypeNote
labelstringread out, not drawn
InputIcon

An icon inside a field, at either end. Wrap the control rather than replacing it; the icon takes no pointer events.

<div class="kb-input-icon">
  <input class="kb-input" id="q" placeholder="Services" />
  <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">…</svg>
</div>
PropTypeNote
iconReactNode
side'start' | 'end'default 'start'
childrenReactNodethe control
Icons

The set the components draw with, generated from lucide. The stroke is currentColor.

IconSun
IconMoon
IconClose
IconArrowUp
IconArrowDown
IconSearch
IconChevronDown
IconCheck
IconMore
IconMenu
IconFile
IconFolder
IconBranch
IconCommit
IconIssue
IconPullRequest
IconTag
<!-- the runtime has the same set. Any svg works: the stroke is currentColor. -->
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
  <path d="m6 9 6 6 6-6" />
</svg>
PropTypeNote
sizenumberdefault 18
...restSVGPropspassed to the svg
Cluster, Stack, Row, Split, Divider

Cluster is a wrapping row. Stack is a column. Row puts fields side by side, 2 or 3 columns. Split makes two halves. Divider is a hairline. All of them take as and pass the rest of their attributes to it, so a Cluster can be the form that carries action and method. The props are generic over as, so the attributes that type-check are the element's own: href on an a, src on an img, and nothing a div does not take.

<div class="kb-cluster">…</div>
<div class="kb-stack kb-stack--tight">…</div>
<div class="kb-row kb-row--3">…</div>
<div class="kb-split">…</div>
<hr class="kb-divider" />

<!-- the class goes on whatever element the row actually is -->
<form class="kb-cluster" action="/search" method="get">…</form>

<!-- kb-push takes the slack, so what follows sits at the far end -->
<div class="kb-cluster"><span>Left</span><span class="kb-push">Right</span></div>
PropTypeNote
asElementTypedefault 'div', 'hr' for Divider
classNamestring
…restComponentPropsWithoutRef<'as'>set on the element
Cluster gap'tight' | 'wide'
Cluster pushboolean
Stack tightboolean
Row cols2 | 3default 2

Forms

Covers field wiring, inputs, choices, and the dropdown

Field

Hands the control its id, aria-describedby and aria-invalid. Renders an Input by default. Pass children for a Select or a Textarea. Form and Fieldset wrap; FormActions is the button row.

Identity
Shown in the index.
Enter a full address.
<div class="kb-field">
  <label class="kb-field__label" for="email">Email</label>
  <input class="kb-input" id="email" name="email" type="email" aria-describedby="email-hint" />
  <span class="kb-field__hint" id="email-hint">Only used to reply.</span>
</div>

<!-- invalid: the row carries the state, and the error is wired by id -->
<div class="kb-field kb-is-invalid">
  <label class="kb-field__label" for="host">Host</label>
  <input class="kb-input" id="host" aria-describedby="host-err" aria-invalid="true" />
  <span class="kb-field__error" id="host-err">Not a hostname.</span>
</div>
PropTypeNote
idstringgenerated when omitted
labelReactNode
hintReactNode
errorReactNodemarks the field invalid
requiredboolean
widebooleanspans the row
children(wiring) => ReactNodeomit for an Input
...inputInputHTMLAttributespassed to the default Input
Input, Textarea

Native inputs with the system's look. Every attribute passes through.

<input class="kb-input" id="name" name="name" />
<input class="kb-input kb-input--sm" id="q" type="search" />
<textarea class="kb-textarea" id="note" rows="4"></textarea>
PropTypeNote
size'sm' | 'md'Input only, 32 or 40px
Group

An input with a fixed word before or after it, in one pill.

.kurobeni.jp
https://
<!-- one pill: the end caps come from the first and last visible piece -->
<div class="kb-group">
  <span class="kb-group__addon">https://</span>
  <input class="kb-input" id="slug" value="storage" />
  <span class="kb-group__addon">.example.com</span>
</div>
PropTypeNote
beforeReactNode
afterReactNode
size'sm' | 'md'
Select

A native select for forms and a button plus listbox drawn by the system. Arrows, Home, End, typeahead, Escape. Flips up when there is no room below.

Selected: osaka
<!-- the runtime replaces this with a button and a listbox, and keeps the select in step -->
<select class="kb-select" id="region" name="region">
  <option value="lhr">London</option>
  <option value="fra">Frankfurt</option>
</select>
PropTypeNote
options{ value, label, disabled? }[]
valuestringcontrolled
defaultValuestring
onChange(value: string) => void
size'sm' | 'md'
namestring
disabledboolean
Combobox

Select with a text field in front, for a list too long to scroll. Typing narrows; arrows and Enter choose; Escape closes and restores. Opens on the side with more room. name submits the chosen value on a hidden input.

Type to narrow the list
<!-- no plain-runtime equivalent yet: a combobox is React only. Use a select, which the
     runtime turns into a listbox with typeahead. -->
<select class="kb-select" id="region">
  <option value="lhr">London</option>
</select>
PropTypeNote
options{ value, label, disabled? }[]
valuestringcontrolled
onChange(value: string) => void
placeholderstring
emptyLabelstringdefault 'No matches'
size'sm' | 'md'
namestringsubmits on a hidden input
disabledboolean
Choice, Switch

Checkbox, radio and switch, drawn by the CSS. The switch says On or Off.

Visibility
Features
<label class="kb-choice">
  <input type="checkbox" name="remember" />
  <span class="kb-choice__text">Keep me signed in</span>
</label>

<label class="kb-choice kb-choice--switch">
  <input type="checkbox" role="switch" name="alerts" />
  <span class="kb-choice__text">Alerts</span>
</label>
PropTypeNote
type'checkbox' | 'radio'Choice only
labelReactNode
descriptionReactNodesmall type under the label
hideLabelbooleanread, not drawn; for a select-all
refRef<HTMLInputElement>for indeterminate
on, offstringSwitch words, default 'On' / 'Off'
Toolbar, HiddenLabel

A row of small controls above a table. HiddenLabel names a control the layout already explains.

<div class="kb-toolbar">
  <label class="kb-vh" for="q">Search</label>
  <input class="kb-input kb-input--sm" id="q" type="search" placeholder="Search" />
  <button class="kb-btn kb-btn--sm kb-push" type="button">Filter</button>
</div>

Data

Tables and tabs

Table

TableWrap scrolls sideways inside its block, with a hint only when columns are hidden. Th and Td take a priority: columns drop low, then mid, then narrow. RowTitle folds the status under the name when that column has gone.

ProjectEnvironmentStatusRequestsp95UpdatedActions
apiHealthyproductionHealthy1,204,553182 ms2 min ago
webDegradedstagingDegraded48,210640 ms14 min ago
storageFailedproductionFailed0—3 h ago
1 selected, showing 1–3 of 3
<!-- data-kb-scrollable marks the edge that hides columns and shows the caption. -->
<div class="kb-table-wrap" data-kb-scrollable>
  <div class="kb-table-scroll" aria-label="Deployments">
    <table class="kb-table">
      <thead>
        <tr>
          <th>Project</th>
          <!-- priority drops the column as the block narrows: low, mid, narrow -->
          <th data-kb-priority="low">Environment</th>
          <th class="kb-is-num">Requests</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>
            <span class="kb-table__primary">api</span>
            <span class="kb-table__sub kb-status kb-status--ok">Healthy</span>
          </td>
          <td data-kb-priority="low">production</td>
          <td class="kb-is-num">1,204,553</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>
<div class="kb-table-foot">…</div>
PropTypeNote
TableWrap labelstringnames the scroll region
TableWrap hintReactNode
TableWrap prosebooleaninside long-form
Th, Td priority'low' | 'mid' | 'narrow'
Th, Td numbooleanright-aligned, tabular digits
Td mutedboolean
RowTitle status{ kind?, label }
Tabs

ARIA tabs. Arrows, Home and End move and select. The list is a tray. fill spans the container and shares the width between the tabs.

All eight deployments.

<!-- the runtime wires the arrows, the roving tabindex and which panel shows -->
<div class="kb-tabs">
  <div class="kb-tabs__list kb-tray" role="tablist" aria-label="Environment">
    <button class="kb-btn" type="button" role="tab" id="t-prod" aria-controls="p-prod" aria-selected="true">
      Production
    </button>
    <button class="kb-btn" type="button" role="tab" id="t-stage" aria-controls="p-stage" aria-selected="false">
      Staging
    </button>
  </div>
  <div class="kb-tabs__panel" role="tabpanel" id="p-prod" aria-labelledby="t-prod">…</div>
  <div class="kb-tabs__panel" role="tabpanel" id="p-stage" aria-labelledby="t-stage" hidden>…</div>
</div>
PropTypeNote
labelstring
items{ id?, label, count?, children }[]
initialnumberdefault 0
fillbooleanthe strip spans its container
onChange(index: number) => void

Frame

The shells an application page sits in

App, AppSide, AppMain, AppTop

A sidebar and a main pane in one page. App holds the sections and draws them twice: the column beside the pane, and under 1024px a Menu on AppTop's title. The current section is ticked. Under 768px the screen goes compact: the space ladder steps down a rung, stats hold two columns, display sizes drop to 24px. Controls never shrink.

<App
  sections={[
    { label: 'Platform', items: [{ href: '/', label: 'Overview', current: true }] },
    { label: 'Account', items: [{ href: '/settings', label: 'Settings' }] },
  ]}
>
  <AppSide panel />
  <AppMain>
    <AppTop title="Deployments">
      <Status kind="ok">Healthy</Status>
    </AppTop>
  </AppMain>
</App>
<!-- data-kb-density auto tightens the scale under 768px. Below 1024px the column is hidden
     and the second title, the one carrying the menu, is drawn instead. -->
<section class="kb-band kb-band--deep kb-band--fill" data-kb-density="auto">
  <div class="kb-col">
    <div class="kb-app">
      <aside class="kb-panel kb-app__side" aria-label="Sections">
        <ul class="kb-nav" aria-label="Sections">
          <li><a class="kb-nav__item" href="/overview" aria-current="page">Overview</a></li>
        </ul>
      </aside>
      <div class="kb-panel kb-app__main">
        <div class="kb-app__top">
          <h2 class="kb-app__title">Overview</h2>
          <h2 class="kb-app__title kb-app__title--pick">
            <button class="kb-app__pick" type="button" data-kb-menu="sections">Overview</button>
            <div class="kb-menu" id="sections" aria-label="Sections">…</div>
          </h2>
          <span class="kb-push"></span>
        </div>
        …
      </div>
    </div>
  </div>
</section>
PropTypeNote
sectionsNavSection[]App, { label?, items }
labelstringApp, names the region and the menu
density'compact' | 'auto' | 'default'App's default is auto
titleReactNodeon AppTop
level1 to 6heading level, default 1
panelbooleanAppSide and AppMain, on a surface
linkLinkComponent | 'a'next/link or a plain anchor
Section

A titled block inside a pane, with no full-bleed ground and no 1200px column.

<section class="kb-section">
  <div class="kb-section__head">
    <h2 class="kb-section__title">Domains</h2>
    <p class="kb-section__aside">4 in use</p>
  </div>
  …
</section>
PropTypeNote
titleReactNode
asideReactNodesits at the far end of the head
level1 to 6default 2
asElementTypedefault 'section'
PanelHead, PanelBody, PanelFoot

A panel with a header and an optional footer.

frankfurt-1

14 variables

Values are written at deploy time.

<!-- divided by ground rather than a rule -->
<div class="kb-panel">
  <div class="kb-panel__head">
    <h3>frankfurt-1</h3>
  </div>
  <div class="kb-panel__body">…</div>
  <div class="kb-panel__foot">…</div>
</div>
PropTypeNote
titleReactNodeon PanelHead
level1 to 6default 2
Auth

One panel centred in the viewport, for pages outside the application. mark puts the seal on an accent side. Wrap the children in Main where this is the whole page.

See a sign-in page
<!-- its own page: no bar, no bands. Drop the aside and the panel centres on the page. -->
<div class="kb-auth">
  <aside class="kb-auth__mark kb-band--beni">
    <div class="kb-stack kb-stack--tight">
      <p class="kb-auth__title">NMan</p>
      <p class="kb-auth__intro">Self-hosted nginx management</p>
    </div>
  </aside>
  <div class="kb-auth__form">
    <main>
      <form class="kb-form" action="/sign-in" method="post">…</form>
    </main>
  </div>
</div>
PropTypeNote
mark{ logo, name, intro, foot }the accent side; omit to centre alone
footReactNodethe line under the panel

Feedback

Dialogs and toasts

Dialog

Native dialog. Open it with ref.current.showModal(). The cross and any button with data-kb-close close it. useTypedConfirm(word) gives inputProps and confirmed for a destructive dialog.

Edit deployment

Delete storage?

This removes the project, its deployments and its subdomain.

<button class="kb-btn" type="button" data-kb-dialog="edit">Edit</button>

<dialog class="kb-dialog" id="edit" aria-labelledby="edit-title">
  <form method="dialog">
    <div class="kb-dialog__head">
      <h2 id="edit-title">Edit service</h2>
      <!-- left empty: the runtime fills the cross -->
      <button class="kb-btn kb-btn--icon" type="button" data-kb-close aria-label="Close"></button>
    </div>
    <div class="kb-dialog__body">…</div>
    <div class="kb-dialog__foot">
      <button class="kb-btn" type="button" data-kb-close>Cancel</button>
      <button class="kb-btn kb-btn--primary" type="button" data-kb-ok>Save</button>
    </div>
  </form>
</dialog>
PropTypeNote
idstring
titleReactNode
actionsReactNodethe foot
refRef<HTMLDialogElement>
closeLabelstringdefault 'Close'
Drawer

A dialog from the edge, for filters or a detail pane. Opened and closed like Dialog.

Filters

Filters, or a detail pane.

<button class="kb-btn" type="button" data-kb-dialog="filters">Filters</button>

<!-- kb-drawer--start comes from the left; the default is the right -->
<dialog class="kb-dialog kb-drawer kb-drawer--start" id="filters" aria-labelledby="filters-title">
  <form method="dialog">
    <div class="kb-dialog__head">
      <h2 id="filters-title">Filters</h2>
      <button class="kb-btn kb-btn--icon" type="button" data-kb-close aria-label="Close"></button>
    </div>
    <div class="kb-dialog__body kb-drawer__body">…</div>
  </form>
</dialog>
PropTypeNote
titleReactNode
actionsReactNodethe foot
side'start' | 'end'default 'end'
refRef<HTMLDialogElement>
ToastProvider, useToast

ToastProvider renders the list at the viewport corner. useToast() returns toast(message, kind?). At most three, five seconds each, paused while hovered.

<!-- the live region, once per page -->
<div class="kb-toasts" aria-live="polite"></div>

<!-- anything with data-kb-toast raises one. kind is ok or err. -->
<button class="kb-btn" type="button" data-kb-toast="Deploy queued" data-kb-kind="ok">
  New deploy
</button>
PropTypeNote
kind'ok' | 'err'optional

Whole page

All of the above, combined on one page.