kasane
The design system for kurobeni.jp.
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.
Covers the root, bands, column, and chrome
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>| Prop | Type | Note |
|---|---|---|
motion | 'full' | 'none' | default 'full' |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
tone | 'hero' | 'tone' | 'deep' | 'beni' | omit for the page ground |
fill | boolean | takes the viewport, for a whole screen |
density | 'compact' | 'auto' | auto is compact under 768px |
as | ElementType | default 'section' |
labelledBy | string | id of the heading inside |
Scroll sideways for more columns.
The 1200px column.
<!-- centred, with the page gutter either side -->
<div class="kb-col">…</div>| Prop | Type | Note |
|---|---|---|
className | string |
Scroll sideways for more columns.
A section head. Title left, aside right. Give it a figure or a note and the title takes a second line beside a mark.
6 projects
<div class="kb-head">
<h2 class="kb-head__title" id="works-title">Works</h2>
<p class="kb-head__aside">6 projects</p>
</div>| Prop | Type | Note |
|---|---|---|
title | ReactNode | |
aside | ReactNode | |
note | ReactNode | |
figure | ReactNode | |
id | string | |
level | 1 | 2 | 3 | 4 | 5 | 6 | default 2 |
Scroll sideways for more columns.
The same head carrying an identity. The mark is centred on the block, not on the title's baseline.
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>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>| Prop | Type | Note |
|---|---|---|
side | ReactNode | |
at | 'start' | 'end' | default 'end' |
Scroll sideways for more columns.
A surface block. On the deep band in the light theme it takes the light theme.
A panel.
<div class="kb-panel">…</div>| Prop | Type | Note |
|---|---|---|
as | ElementType | default 'div' |
className | string |
Scroll sideways for more columns.
The main landmark.
<main id="main">…</main>| Prop | Type | Note |
|---|---|---|
id | string | default 'main' |
Scroll sideways for more columns.
First in the page. Visible on focus.
<!-- first in the body, so it is the first thing the keyboard reaches -->
<a class="kb-skip" href="#main">Skip to content</a>| Prop | Type | Note |
|---|---|---|
href | string | default '#main' |
label | string | default 'Skip to content' |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
brand | { name, domain?, href } | |
items | NavItem[] | { href, label, current? } |
actions | ReactNode | sign in, sign up, an account menu; pair with collapse |
variant | 'slim' | 32px controls throughout |
label | string | nav label, default 'Primary' |
link | LinkComponent | 'a' | next/link or a plain anchor |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
className | string | added to the button |
Scroll sideways for more columns.
[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>A block in the opposite theme of the page.
<!-- the runtime stamps the opposite theme on it and keeps it opposite when the page flips -->
<div class="kb-panel" data-kb-invert>…</div>| Prop | Type | Note |
|---|---|---|
className | string |
Scroll sideways for more columns.
Covers the hero, tiles, collections, stats, and long-form blocks
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>| Prop | Type | Note |
|---|---|---|
title | ReactNode | |
name | ReactNode | |
intro | ReactNode | |
actions | ReactNode | buttons or links |
id | string | heading id, default 'hero-title' |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
href | string | |
kind | ReactNode | |
year | ReactNode | |
title | ReactNode | |
level | HeadingLevel | default 3 |
small | boolean | |
link | LinkComponent | 'a' | next/link or a plain anchor |
children | ReactNode | the description |
Scroll sideways for more columns.
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.
The design system for kurobeni.jp.
Moon phase and tide tracker.
Plain-text notes.
The edge node that serves Europe.
<!-- 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>| Prop | Type | Note |
|---|---|---|
Collection density | 'row' | 'tile' | default 'row' |
Collection empty | ReactNode | shown as an Empty when there are no items |
title | ReactNode | |
href | string | makes the title a link |
level | HeadingLevel | default 3 |
marks | ReactNode | |
aside | ReactNode | |
link | LinkComponent | 'a' | next/link or a plain anchor |
children | ReactNode | ItemText is the description |
Scroll sideways for more columns.
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.
<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>| Prop | Type | Note |
|---|---|---|
value | ReactNode | |
unit | ReactNode | after the value, in secondary ink |
label | ReactNode | |
delta | ReactNode | |
direction | 'up' | 'down' | |
sentiment | 'good' | 'bad' | 'neutral' | default 'neutral' |
size | 'lg' | 'xl' | 40px or 72px, default 32 |
bar | number | a percentage, drawn under the label |
href | string | makes the whole stat a link |
onSelect | () => void | makes the whole stat a button |
link | LinkComponent | 'a' | next/link or a plain anchor |
Stats flush | boolean | drops the tile ground and inset from every stat inside |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
title | ReactNode | |
text | ReactNode | |
actions | ReactNode | |
id | string | heading id, default 'cta-title' |
Scroll sideways for more columns.
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.
The unit is 8px.
Every gap, padding and control height is a multiple of it. --kb-space-8
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.
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>| Prop | Type | Note |
|---|---|---|
as | ElementType | default 'article' |
plain | boolean | on the page ground instead of a panel |
document | boolean | a rendered file: no measure, and a quote is a quote |
tight | boolean | a few lines, not a document |
html | string | markup rendered elsewhere, set on the block |
className | string |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
items | { href, label }[] | |
label | string | default 'On this page' |
link | LinkComponent | 'a' | next/link or a plain anchor |
Scroll sideways for more columns.
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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
values | number[] | |
label | string | names the shape and its range |
width | number | default 120 |
height | number | default 32 |
area | boolean | fills under the line |
point | boolean | marks the last value, default true |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
--kb-series-1 | ochre | |
--kb-series-2 | teal | |
--kb-series-3 | pine | |
--kb-series-4 | mulberry | |
--kb-series-5 | plum | |
--kb-series-6 | indigo | |
--kb-series-7 | moss | |
--kb-series-8 | olive |
Scroll sideways for more 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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
series | ColumnSeries[] | up to eight; the palette starts again at nine |
ColumnSeries name | string | the name in the key |
ColumnSeries values | number[] | one per slot, oldest first |
label | string | what the columns measure |
ticks | ReactNode[] | the ends name the span |
titles | string[] | one per slot, shown on hover |
max | number | the shared ceiling; the largest value when omitted |
height | number | the plot in px, default 96 |
Scroll sideways for more columns.
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.
<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>| Prop | Type | Note |
|---|---|---|
items | ReactNode[] | in the order the plot drew them |
ink | boolean | the ink ladder instead of the series palette |
Scroll sideways for more columns.
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.
<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>| Prop | Type | Note |
|---|---|---|
days | { date: string; value: number }[] | |
label | string | names the grid for a screen reader |
weeks | number | default 53 |
legend | [ReactNode, ReactNode] | the key under the grid, empty end first. Default ['Less', 'More'] |
Scroll sideways for more columns.
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.
<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>| Prop | Type | Note |
|---|---|---|
Listing head | ReactNode | the strip above the rows |
ListingHead figure | ReactNode | |
ListingHead meta | ReactNode | trailing, in secondary ink |
ListingHead action | ReactNode | |
ListingRow figure | ReactNode | |
ListingRow title | ReactNode | the primary line. It takes the width and truncates |
ListingRow href | string | |
ListingRow link | Anchor | default 'a' |
ListingRow note | ReactNode | a second line, in secondary ink |
ListingRow meta | ReactNode | |
ListingRow action | ReactNode | |
ListingGroup children | ReactNode | what the run of rows under it has in common |
ListingTable children | ReactNode | a table body, for columns that line up |
Scroll sideways for more columns.
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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
Log label | string | names the list |
Log gutter | string | the tag column width; read off the tags when omitted |
LogLine at | ReactNode | the time, already formatted |
LogLine dateTime | string | machine-readable, when at is not |
LogLine tag | ReactNode | the status or the level, in the gutter |
LogLine kind | 'warn' | 'err' | colours the tag and nothing else |
LogLine method | ReactNode | |
LogLine path | ReactNode | |
LogLine children | ReactNode | the message, in secondary ink |
LogDay children | ReactNode | a date, as a line in the stream |
Scroll sideways for more columns.
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);
}.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>| Prop | Type | Note |
|---|---|---|
code | string | one line per newline |
lines | ReactNode[] | the lines already marked up |
start | number | the first line number, default 1 |
anchor | string | makes each number a link, at #L1 |
gutter | ReactNode[] | one entry a line, before the numbers |
gutterWidth | string | an entry's width, default 10ch |
label | string | names the block |
Scroll sideways for more columns.
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.
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | .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); | |
| 14 | 14 | gap: var(--kb-space-12); | |
412 more lines not shown.
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>| Prop | Type | Note |
|---|---|---|
DiffFile path | ReactNode | |
DiffFile from | ReactNode | the old path, where the file moved |
DiffFile actions | ReactNode | the counts and the state |
DiffFile note | ReactNode | stands in for rows that are not drawn |
DiffRow kind | 'add' | 'del' | 'ctx' | 'hunk' | default 'ctx' |
DiffRow before | ReactNode | the line number on the old side |
DiffRow after | ReactNode | and on the new side |
Scroll sideways for more columns.
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.
<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>| Prop | Type | Note |
|---|---|---|
value | number | clamped to the range |
min | number | default 0 |
max | number | default 100 |
zones | boolean | the range carries the verdict, so the marker takes no colour |
fill | boolean | fills from the start instead of marking a point |
runs | ScaleRun[] | replaces the track with a history |
parts | ScalePart[] | shares of a whole, stepping down one ink ladder |
legend | boolean | names the parts under the track, as a Key |
ticks | ReactNode[] | the ends name the range |
label | string | what the range measures |
Scroll sideways for more columns.
Key and value in columns, one column a subject. A table across a full band is mostly empty.
<dl class="kb-facts">
<div class="kb-facts__group">
<dt>Dew point</dt><dd>9.4 °C</dd>
<dt>Heat index</dt><dd>20.8 °C</dd>
</div>
<div class="kb-facts__group">
<dt>Sunrise</dt><dd>06:52</dd>
<dt>Sunset</dt><dd>19:04</dd>
</div>
</dl>| Prop | Type | Note |
|---|---|---|
children | FactGroup elements |
Scroll sideways for more columns.
A note in a box. err is an alert.
<p class="kb-callout kb-callout--warn">
<strong>Certificate expires in 6 days</strong>
Renew before Friday.
</p>| Prop | Type | Note |
|---|---|---|
kind | 'neutral' | 'notice' | 'ok' | 'warn' | 'err' | default 'neutral' |
title | ReactNode |
Scroll sideways for more columns.
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.
@kasane/design/css, built from the tokens. Import it once in the root layout.@kasane/design/js wires the parts that need behaviour.<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>| Prop | Type | Note |
|---|---|---|
summary | ReactNode | the always-visible line |
name | string | shared name closes the others |
open | boolean | open on first paint |
children | ReactNode | the panel |
Scroll sideways for more columns.
An empty state.
No deployments yet. Push to main to create one.
<div class="kb-empty">
<p>No deployments yet.</p>
</div>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>Covers buttons, words, identity, and layout
Passes every button attribute through. ButtonLink is the same look on an anchor. LinkButton is the link look; href renders an anchor.
<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>| Prop | Type | Note |
|---|---|---|
variant | 'secondary' | 'primary' | 'accent' | 'danger' | default 'secondary' |
size | 'sm' | 'md' | 'lg' | 32, 40, 48px |
icon | boolean | a square button holding one icon, needs aria-label |
tip | string | tooltip on hover and focus |
push | boolean | margin-left: auto |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
label | string | |
role | 'group' | 'tablist' | default 'group' |
Scroll sideways for more columns.
A word in its colour.
<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>| Prop | Type | Note |
|---|---|---|
kind | 'neutral' | 'ok' | 'warn' | 'err' | default 'neutral' |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
as | ElementType | default 'p' |
size | 12 to 72 | one of the nine sizes |
muted, small, caps, num | boolean |
Scroll sideways for more columns.
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.
<figure class="kb-figure">
<img src="/chart.png" alt="Requests over seven days" />
<figcaption>Requests, 7 days.</figcaption>
</figure>| Prop | Type | Note |
|---|---|---|
caption | ReactNode | |
label | string | FigureSwatch, names the placeholder |
Scroll sideways for more columns.
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.
--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>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.
<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>| Prop | Type | Note |
|---|---|---|
initials | ReactNode | |
src | string | the picture; initials are the fallback |
name | string | |
size | 'md' | 'lg' |
Scroll sideways for more columns.
Breadcrumb. The last entry has no href and is the page.
<!-- 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>| Prop | Type | Note |
|---|---|---|
trail | { href?, label }[] | |
link | LinkComponent | 'a' | next/link or a plain anchor |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
pages | ({ href, label, current? } | null)[] | |
prev | string | |
next | string | |
label | string | default 'Pagination' |
push | boolean | |
link | LinkComponent | 'a' | next/link or a plain anchor |
Scroll sideways for more columns.
A progressbar. Skeleton is one shimmering line.
<progress class="kb-progress" value="25" max="100" aria-label="Build"></progress>
<div class="kb-skeleton" style="width: 80%"></div>| Prop | Type | Note |
|---|---|---|
value | number | |
max | number | default 100 |
label | string |
Scroll sideways for more columns.
A ring that turns while something loads. The label is read, not drawn. Takes the current ink; motion none does not stop it.
<span class="kb-spin" aria-hidden="true"></span>
<span class="kb-vh" role="status">Loading</span>| Prop | Type | Note |
|---|---|---|
label | string | read out, not drawn |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
icon | ReactNode | |
side | 'start' | 'end' | default 'start' |
children | ReactNode | the control |
Scroll sideways for more columns.
The set the components draw with, generated from lucide. The stroke is currentColor.
<!-- 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>| Prop | Type | Note |
|---|---|---|
size | number | default 18 |
...rest | SVGProps | passed to the svg |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
as | ElementType | default 'div', 'hr' for Divider |
className | string | |
…rest | ComponentPropsWithoutRef<'as'> | set on the element |
Cluster gap | 'tight' | 'wide' | |
Cluster push | boolean | |
Stack tight | boolean | |
Row cols | 2 | 3 | default 2 |
Scroll sideways for more columns.
Covers field wiring, inputs, choices, and the dropdown
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.
<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>| Prop | Type | Note |
|---|---|---|
id | string | generated when omitted |
label | ReactNode | |
hint | ReactNode | |
error | ReactNode | marks the field invalid |
required | boolean | |
wide | boolean | spans the row |
children | (wiring) => ReactNode | omit for an Input |
...input | InputHTMLAttributes | passed to the default Input |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
size | 'sm' | 'md' | Input only, 32 or 40px |
Scroll sideways for more columns.
An input with a fixed word before or after it, in one pill.
<!-- 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>| Prop | Type | Note |
|---|---|---|
before | ReactNode | |
after | ReactNode | |
size | 'sm' | 'md' |
Scroll sideways for more columns.
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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
options | { value, label, disabled? }[] | |
value | string | controlled |
defaultValue | string | |
onChange | (value: string) => void | |
size | 'sm' | 'md' | |
name | string | |
disabled | boolean |
Scroll sideways for more columns.
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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
options | { value, label, disabled? }[] | |
value | string | controlled |
onChange | (value: string) => void | |
placeholder | string | |
emptyLabel | string | default 'No matches' |
size | 'sm' | 'md' | |
name | string | submits on a hidden input |
disabled | boolean |
Scroll sideways for more columns.
Checkbox, radio and switch, drawn by the CSS. The switch says On or Off.
<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>| Prop | Type | Note |
|---|---|---|
type | 'checkbox' | 'radio' | Choice only |
label | ReactNode | |
description | ReactNode | small type under the label |
hideLabel | boolean | read, not drawn; for a select-all |
ref | Ref<HTMLInputElement> | for indeterminate |
on, off | string | Switch words, default 'On' / 'Off' |
Scroll sideways for more columns.
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>Tables and tabs
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.
| Project | Environment | Status | Requests | p95 | Updated | Actions |
|---|---|---|---|---|---|---|
| apiHealthy | production | Healthy | 1,204,553 | 182 ms | 2 min ago | |
| webDegraded | staging | Degraded | 48,210 | 640 ms | 14 min ago | |
| storageFailed | production | Failed | 0 | — | 3 h ago |
Scroll sideways for more columns.
<!-- 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>| Prop | Type | Note |
|---|---|---|
TableWrap label | string | names the scroll region |
TableWrap hint | ReactNode | |
TableWrap prose | boolean | inside long-form |
Th, Td priority | 'low' | 'mid' | 'narrow' | |
Th, Td num | boolean | right-aligned, tabular digits |
Td muted | boolean | |
RowTitle status | { kind?, label } |
Scroll sideways for more columns.
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.
Five in production.
<!-- 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>| Prop | Type | Note |
|---|---|---|
label | string | |
items | { id?, label, count?, children }[] | |
initial | number | default 0 |
fill | boolean | the strip spans its container |
onChange | (index: number) => void |
Scroll sideways for more columns.
The shells an application page sits in
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>| Prop | Type | Note |
|---|---|---|
sections | NavSection[] | App, { label?, items } |
label | string | App, names the region and the menu |
density | 'compact' | 'auto' | 'default' | App's default is auto |
title | ReactNode | on AppTop |
level | 1 to 6 | heading level, default 1 |
panel | boolean | AppSide and AppMain, on a surface |
link | LinkComponent | 'a' | next/link or a plain anchor |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
title | ReactNode | |
aside | ReactNode | sits at the far end of the head |
level | 1 to 6 | default 2 |
as | ElementType | default 'section' |
Scroll sideways for more columns.
A panel with a header and an optional footer.
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>| Prop | Type | Note |
|---|---|---|
title | ReactNode | on PanelHead |
level | 1 to 6 | default 2 |
Scroll sideways for more columns.
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.
<!-- 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>| Prop | Type | Note |
|---|---|---|
mark | { logo, name, intro, foot } | the accent side; omit to centre alone |
foot | ReactNode | the line under the panel |
Scroll sideways for more columns.
Dialogs and toasts
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.
<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>| Prop | Type | Note |
|---|---|---|
id | string | |
title | ReactNode | |
actions | ReactNode | the foot |
ref | Ref<HTMLDialogElement> | |
closeLabel | string | default 'Close' |
Scroll sideways for more columns.
A dialog from the edge, for filters or a detail pane. Opened and closed like Dialog.
<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>| Prop | Type | Note |
|---|---|---|
title | ReactNode | |
actions | ReactNode | the foot |
side | 'start' | 'end' | default 'end' |
ref | Ref<HTMLDialogElement> |
Scroll sideways for more columns.
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>| Prop | Type | Note |
|---|---|---|
kind | 'ok' | 'err' | optional |
Scroll sideways for more columns.
All of the above, combined on one page.