The Staple design system, current. Every component in the kit is shown live beside the rules that govern it, rendered from its own spec sheet — the 21 rebuilt in the v3.2 gap-closure pass and the 25 that predate it. Switch theme in the header; all three are verified. Typography is Noto Sans throughout, on a shadcn-named --text-* / --font-weight-* scale.
UI Sanity Bible
QAUI-SANITY-BIBLE.mdOne self-contained file for auditing a built screen against the kit — the seven audit passes in order, every token value printed, the 83 universal checks, the known-good exemptions and the measurement traps. Hand this to a developer or to QA; nothing else needs to be open. This section is generated from the file itself, so it cannot drift from it.
Canvas — the app ground
foundationcanvas.mdThe page an app sits on, as distinct from the default surface a control sits in. shadcn gives both the same #ffffff, so the distinction never surfaced and this kit inherited it — meanwhile the flows set body{background:#f4f6f8} by hand because the kit never defined it. --canvas started at that measured value and is now one step darker, so a --card panel separates from the ground instead of floating on an identical white. It could not just be --background repointed: 22 rules use that as the default control fill, so every field would have turned grey. Hence a fourth surface level (§ 4).
| Token | Light | Dark | High Contrast | Role |
|---|---|---|---|---|
--canvas | #eef1f3 | #142226 | #000000 | The page the app sits ON. Viewport shell only |
--background | #ffffff | #142226 | #000000 | The default surface in it, and the default control fill |
--card | #ffffff | #233a3e | #000000 | Elevated but in flow: cards, panels, table bodies |
--popover | #ffffff | #2f5155 | #000000 | Floating out of flow: menus, dialogs, drawers |
#f5f5f5) as a page ground — they are recessed fills inside a surface, and being near-identical to --canvas in light is exactly how a wrong level stays invisible until someone switches to dark.Canvas
--canvas · --canvas-foreground · .ck-canvas
What It Is
The app ground — the page an application sits on, as distinct from the default surface a control sits in.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-canvas | the shell. Goes on the element that owns the viewport |
.ck-canvas-inset | a region sitting back from the surface around it — a preview well, an empty column. Carries the panel radius |
Tokens
Why this had to be a new token
shadcn gives --background and --card the same value (#ffffff), so the distinction never surfaces. This kit inherited that: a panel had no separation from the page, and the level was carried entirely by --panel-border.
But --background couldn't simply become grey — the kit uses it as the default control fill in 22 places: .ck-input, .ck-select, .ck-textarea, .ck-dd-trigger, .ck-btn's base, the .ck-switch thumb, the .ck-check and .ck-radio boxes, .ck-empty. Repointing it would turn every field in the product grey.
So the token set has four surface levels rather than three.
The value was already in the product as a literal — reconciliation-flow.html sets body { background:#f4f6f8 } by hand, and the kit never defined it, so the app ground was a per-page decision. The token started as that measured value and is now #eef1f3, one step darker — which lifts a --card panel's separation from the ground from 1.08 to 1.13:1.
The Four Surface Levels
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--canvas | #eef1f3 | #142226 | #000000 | the page the app sits on — viewport shell only |
--background | #ffffff | #142226 | #000000 | #ffffff · the default surface in it, and the default control fill |
--card | #ffffff | #233a3e | #000000 | #ffffff · elevated but in flow: cards, panels, table bodies |
--popover | #ffffff | #2f5155 | #000000 | #ffffff · floating out of flow: menus, dialogs, drawers, tooltips |
Light is the only theme where canvas and background diverge. Dark (#142226) and high contrast (#000000) already separate the ground from --card, so --canvas equals --background there. A fourth value would be a distinction without a difference — and in high contrast, a near-black competing with #000000 would be actively worse.
Special Rules
Rules That Matter
.ck-canvasgoes on the element that owns the viewport, never on a panel. A panel on--canvasreads as a hole in the page rather than a surface on it.- Anything laid on the canvas takes
--cardor--popover, whichever its role calls for. A card on the canvas needs no shadow: the value difference carries it in light, and--cardcarries it in dark. - Never use
--mutedor--secondary(#f5f5f5) as a page ground. They're recessed fills inside a surface, and both are near-identical to--canvasin light — which is exactly how a wrong level stays invisible until someone switches to dark. - Check
--inputagainst--canvas, not only against--background, for anything whose edge against the ground is its only affordance.
Accessibility
--canvas-foregroundon--canvasis well above AA in all three themes (light is#05262eon#eef1f3, 13.99:1). It exists because Law 3 requires the pair, and it's the partner to use for text laid directly on the ground.- The canvas isn't an affordance, so the 3:1 non-text floor doesn't bind on it.
Notes
.ck-canvas sets min-height: 100vh, which is layout rather than a value.
Role Tokens
v3.2tokens.mdFour token families from the DES-667 review audit. Each names an intent that screens were expressing with a borrowed palette hue or a component-scoped token: the line in focus, the AI-extracted mark, the extraction confidence scale, and categorical row identity. Every one is declared in all three themes and inside a light-scoped region.
Match — the line in focus
--match surface, --match-foreground text, --match-border edgeAI — the extracted mark
Confidence
Series — categorical row identity
Tokens
| Token | Light | Dark | High Contrast | What It Is For | Replaces |
|---|---|---|---|---|---|
--match | #fef3c7 | #3d4a3b | #2a2000 | the line in focus across the documents being compared — its surface | was --amber-bg |
--match-foreground | #b45309 | #fbbf24 | #fbbf24 | text on it | was --amber-soft-foreground |
--match-border | #f2a618 | #f7b83d | #ffbb33 | its edge | was --warning |
--ai | #ede9fc | #364853 | #1a0a2a | the AI-extracted mark's surface | was --purple-bg |
--ai-foreground | #6c45c1 | #c4b0f0 | #c4b0f0 | text or glyph in the AI tone | was --purple-soft-foreground |
--ai-border | #6c45c1 | #c4b0f0 | #c4b0f0 | the AI mark's stroke — .is-ai fields, the OCR box; focus adds --focus-ring-ai | was --purple-soft-foreground |
--confidence-ok | #99b7bf | #99b7bf | #5eead4 | extraction confidence: good | --dfp-ok reads it |
--confidence-confirmed | #3d707c | #66a3b0 | #66d9ef | confirmed by a person | --dfp-confirmed reads it |
--confidence-warn | #f19546 | #f5a95e | #fbbf24 | low confidence — check it | --dfp-warn reads it |
--series-1 | #047857 | #6ee7b7 | #6ee7b7 | categorical row identity 1 of 8 | was --emerald |
--series-2 | #2562ee | #92c4fe | #92c4fe | categorical row identity 2 of 8 | was --blue |
--series-3 | #be123c | #fb7185 | #fb7185 | categorical row identity 3 of 8 | was --rose |
--series-4 | #6d28d9 | #a78bfa | #a78bfa | categorical row identity 4 of 8 | was --violet |
--series-5 | #b91c1c | #fca5a5 | #fca5a5 | categorical row identity 5 of 8 | was --crimson |
--series-6 | #3d6b50 | #81c784 | #81c784 | categorical row identity 6 of 8 | was --sage |
--series-7 | #b45309 | #fbbf24 | #fbbf24 | categorical row identity 7 of 8 | was --amber |
--series-8 | #0e7490 | #67e8f9 | #67e8f9 | categorical row identity 8 of 8 | was --cyan |
Rules
- Name the intent, not the hue. A row in focus is
--match, not--amber-bg; an AI mark is--ai-border, not--purple-soft-foreground. - A surface takes its own foreground (Law 3):
--matchwith--match-foreground,--aiwith--ai-foreground. - Confidence is one scale everywhere. A table cell's low-confidence mark and the fields panel's read the same
--confidence-*;--dfp-*is an alias. - Series is for identity, not status. Eight categorical colours for rows that must be told apart; never to say good, bad or pending.
Typography — Noto Sans
foundationtypography.mdOne family for everything: --font-sans is Noto Sans and carries everything, including values and IDs — there is no --font-mono. There is no display, serif or mono face either — one family carries the product (§ 6). The variable axis covers 100–900, so 400 / 500 / 600 all come from one file. The scale is whole pixels only — 8, 9, 10, 11, 12, 13, 14, 16, 18, 20, 22, 24, 28, 32. It used to carry 10.5, 11.5, 12.5, 13.5 and 15px; a half-pixel is not a difference a reader can perceive, and it made adjacent slots indistinguishable. Every font size in the kit is now a token — there are no literals left, not even the 8px notification badge. Reuse a slot; do not invent a size.
Bold at Every Step — .ck-text.weight-bold
One class, two axes. .size-* names the step in the type scale, .weight-* the weight — both are the kit’s existing scales rather than new ones, so .ck-text.size-2xl.weight-bold is 18px at 700. Until now the kit gave nothing to carry a type token with, so a flow that wanted bold wrote font:700 18px and the scale went unreferenced — this page’s own weight specimen did exactly that. Every step is here, not just the ones in use today: a scale you can only reach half of is one people go around.
| Token | px | Weight | Specimen | Used For |
|---|---|---|---|---|
--text-3xs | 8 | 500 | Reconciled 42 invoices · Bao Sheng | Notification badge digits — the smallest mark |
--text-2xs | 9 | 600 | Reconciled 42 invoices · Bao Sheng | Counter digits at .size-sm |
--text-xs | 10 | 600 | Reconciled 42 invoices · Bao Sheng | Uppercase group labels |
--text-sm | 11 | 500 | Reconciled 42 invoices · Bao Sheng | Table column header titles |
--text-md | 12 | 400 | Reconciled 42 invoices · Bao Sheng | Table cell data, hints, helper text, body copy |
--text-md | 12 | 600 | Reconciled 42 invoices · Bao Sheng | Badge / pill text |
--text-base | 13 | 400 | Reconciled 42 invoices · Bao Sheng | Body: labels, timestamps, input, dropdown, search, rows, options |
--text-base | 13 | 600 | Reconciled 42 invoices · Bao Sheng | Button label; selected option |
--text-lg | 14 | 600 | Reconciled 42 invoices · Bao Sheng | Card titles |
--text-xl | 16 | 600 | Reconciled 42 invoices · Bao Sheng | Panel / dialog / drawer titles; inactive breadcrumb |
--text-2xl | 18 | 600 | Reconciled 42 invoices · Bao Sheng | Header bar title; active breadcrumb |
--text-3xl | 20 | 600 | Reconciled 42 invoices · Bao Sheng | Auth / onboarding title |
--text-4xl | 22 | 600 | Reconciled 42 invoices · Bao Sheng | Reserved — a page hero |
--text-5xl | 24 | 600 | Reconciled 42 invoices · Bao Sheng | Reserved |
--text-6xl | 28 | 600 | Reconciled 42 invoices · Bao Sheng | Reserved |
--text-7xl | 32 | 600 | Reconciled 42 invoices · Bao Sheng | Reserved |
design-system.css?v=7ab031ca itself via an @import of the Google Fonts variable axis, so linking the token file is enough. Before this pass nothing fetched the family, so every standalone prototype rendered in -apple-system and the token was unenforceable.Typography
--font-sans
What It Is
One family for everything. Noto Sans carries all product UI — including values, IDs, amounts and code, which line up on font-variant-numeric:tabular-nums rather than on a second typeface. There is no display, serif or mono face.
Basic Information
Loading the Font
design-system.css?v=7ab031ca imports the Google Fonts variable axis itself:
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap");A few things about that line are deliberate:
- It's in the token file, not per page, so "link
design-system.css?v=7ab031caonly" actually holds. - The variable axis (100..900) means 400 / 500 / 600 all come from one file rather than three static faces.
display=swappaints text in the fallback immediately and reflows when Noto arrives, rather than blocking first paint.- The
@importmust stay ahead of every rule in the file. A stylesheet's@importis only valid before any style rule. Comments before it are fine. - There is no display face. Fraunces was marketing-only and never fetched, so any product screen that asked for it rendered in Times New Roman — a third typeface arriving by fallback rather than by choice. Display weight is
--font-sansat a heavier weight and a larger step.
The Google family name is "Noto Sans", second in the chain. "Noto Sans Variable" ahead of it is the @fontsource name and resolves only inside the React app, which loads the font through npm.
Optionally add <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> to a page head if first paint matters. It shortens the handshake but isn't needed for correctness.
Worth knowing: before v3.2 nothing in the kit fetched the family at all. --font-sans asked for Noto Sans, no @font-face or link existed anywhere, and the machine didn't have it installed — so every standalone prototype silently rendered in -apple-system. The token was declared and inert.
Tokens
The Families
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--font-sans | "Noto Sans Variable", "Noto Sans", -apple-system, BlinkMacSystemFont, sans-serif | — | var(--font-sans) | "Noto Sans Variable", "Noto Sans", -apple-system, BlinkMacSystemFont, sans-serif · everything |
The Scale
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--text-3xs | 8px | — | var(--text-3xs) | 8px · notification badge digits — the smallest mark in the product |
--text-2xs | 9px | — | var(--text-2xs) | 9px · counter digits at .size-sm |
--text-xs | 10px | — | var(--text-xs) | 10px · uppercase group labels, .ck-kbd.size-sm |
--text-sm | 11px | — | var(--text-sm) | 11px · table column headers (medium), small counters, keycaps |
--text-md | 12px | — | var(--text-md) | 12px · table cell data, hints, helper text, badge text, body copy |
--text-base | 13px | — | var(--text-base) | 13px · body — labels, timestamps, input, dropdown, search, list rows, options, buttons |
--text-lg | 14px | — | var(--text-lg) | 14px · card titles (semibold) |
--text-xl | 16px | — | var(--text-xl) | 16px · panel / dialog / drawer titles, inactive breadcrumb (semibold) |
--text-2xl | 18px | — | var(--text-2xl) | 18px · header bar title and the active breadcrumb (semibold) |
--text-3xl | 20px | — | var(--text-3xl) | 20px · auth and onboarding title |
--text-4xl | 22px | — | var(--text-4xl) | 22px · reserved — a page hero |
--text-5xl | 24px | — | var(--text-5xl) | 24px · reserved |
--text-6xl | 28px | — | var(--text-6xl) | 28px · reserved |
--text-7xl | 32px | — | var(--text-7xl) | 32px · reserved |
Whole pixels only. The scale used to carry 10.5, 11.5, 12.5, 13.5 and 15px. A half-pixel is not a difference a reader can perceive, and it made adjacent slots indistinguishable — so the scale is now 8, 9, 10, 11, 12, 13, 14, 16, 18, 20, 22, 24, 28, 32.
Where Each Size Is Used
18px semibold — the header bar title and the active breadcrumb. The active crumb steps up in size as well as weight, against the trail's 16px. It is keyed to aria-current="page", so the drawing cannot drift from what a screen reader announces.
16px semibold — panel, dialog and drawer titles, and the inactive breadcrumb. That covers .ck-dialog-title, .ck-drawer-title, .ck-notif-title, .ck-folder-title, .ck-dfp-title and .ck-dd-picker-title. One size for every floating or panel header.
14px semibold — card titles. .ck-card-title, .ck-empty-title, .ck-error-state-title, .ck-dropzone-title, .ck-connector-name, .ck-feature-title.
13px regular — the body size. Small labels, timestamps, inputs, selects, textareas, dropdown triggers, search boxes, list rows, menu items, options and button labels. If you are unsure what size a piece of text should be, it is this one.
12px regular — table cell data, hints and helper text. Also badge and pill text, sub-titles, alert messages and card descriptions.
11px medium — table column header titles. Deliberately lighter than the cells beneath them: a column header is a label, not data, so it steps down in size and up in weight rather than shouting.
10px semibold — uppercase group labels. .ck-menu-label, .ck-dfp-group-label, .ck-sidebar-flyout-title.
9px and 8px — digits only. Counters at .size-sm and the notification badge. Never body text.
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--font-weight-normal | 400 | — | var(--font-weight-normal) | 400 |
--font-weight-medium | 500 | — | var(--font-weight-medium) | 500 |
--font-weight-semibold | 600 | — | var(--font-weight-semibold) | 600 |
Special Rules
Every piece of text in a flow must carry a token
This binds when building flows, not only when building the kit. A flow that writes font-size: 14px has opted out of the scale, and the next size change will silently miss it. There is no font size anywhere in the kit that is not a token — hold a flow to the same standard.
Use the token the role calls for, not the one whose number happens to match: a card title is 14px semibold because it is a card title, and if that size ever moves, the token moves with it.
Rules That Matter
- Never write a literal font family or size. Always through the token:
font: var(--font-weight-semibold) var(--text-base) var(--font-sans). - Don't invent a new size for a new component — reuse a slot. The scale has ten, which is more than enough.
- There is no display face. Display weight is
--font-sansat--font-weight-semiboldand a larger step — and there is no--font-weight-bold, so asking for one silently invalidates afontshorthand. - Never go below 11px. Pill text at 11px/600 is the floor, and it doesn't count as large text, so the full 4.5:1 contrast requirement applies.
- Weight is emphasis, not state. Don't communicate meaning through weight alone.
Accessibility
- Amounts, IDs and any column of digits take
font-variant-numeric: tabular-nums, so they line up without a second typeface. That is what--font-monoused to be for, and why it is gone.
Checking it actually loaded
document.fonts.check('600 16px "Noto Sans"') must return true, and a string set in var(--font-sans) must measure differently from the same string in -apple-system.
Notes
The font shorthand does accept var() substitutions — worth knowing, since a shorthand that fails to parse drops the whole declaration rather than degrading.
Nothing is a literal any more. The notification badge, the pill counter and the standalone counter all read the scale now, so there is no font size in the kit that is not a token.
Icon
kiticon.mdEvery icon is Lucide: 24×24 viewBox, currentColor, 2px stroke, round caps and joins. One glyph size (16px) in one box (24px). Two colours only — a third tone is a defect, not a variant.
Sizes — small, default, navigation
--icon-size-sm 12px in --icon-box-sm 18px — the same 2:3 ratio, colour, stroke and tones as the default; for dense rows where a 24px frame crowds.A Count On An Icon — .ck-ico-count
The icon is the subject and the count qualifies it, so the bubble is half the box — 12px on the 24px icon, 16px on the 40px rail icon. Matched to the glyph it stops being a count on an icon and becomes two marks the same size sharing a corner. Past two digits it grows sideways, never taller. The ring is--background, so it separates from the glyph in all three themes.Icon
.ck-ico
What It Is
The glyph that sits beside a label, inside a button, or on its own as an icon-only control. Every icon in the product is a Lucide icon (lucide.dev, MIT).
Basic Information
How to Write One
<span class="ck-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
</span>Use only these children: path, line, polyline, polygon, circle, ellipse, rect. No transforms, filters, fills, explicit stroke colours, or <use>.
One exception to the geometry: a filled glyph that has no stroke is left alone. Forcing a stroke onto it would hollow it out.
Size
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--icon-size | 16px | 16px | 16px | 16px · the glyph — the only icon size in the product |
--icon-box | 24px | 24px | 24px | 24px · the layout box; glyph centred, 4px clear all round |
--icon-stroke | 2 | 2 | 2 | 2 · unitless, drops straight into stroke-width |
Inside a control, the glyph keeps 16px but loses the 24px box. A 24px box leaves no room in a 20px .ck-pill.size-sm, so within a control the glyph sits in that control's own padding. This is the one exception, and the alternatives were worse: a second glyph size, or a small pill that can't hold an icon.
Colour
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--icon-color | #738f96 | #bcd7dd | #e0e0e0 | everything that is not destructive |
--icon-color-destructive | #d90500 | #fe9b98 | #ff4444 | delete, remove, disconnect, error |
Radius
The hover fill is a rounded square: --radius-md (8px), or --radius-sm (6px) at .size-xs. Never a circle.
Tokens
States
A bare .ck-ico is decorative and has no states. States apply when you add .is-interactive, or when you use Icon Button.
| Normal | Destructive | |
|---|---|---|
| Rest | --icon-color | --icon-color-destructive |
| Hover | --foreground on --hover-bg | --destructive-soft-foreground on --destructive-bg |
| Active | --selected-bg | --destructive fill, --destructive-foreground glyph |
| Selected | --primary on --selected-bg | --destructive-soft-foreground on --destructive-bg |
| Focus | --focus-ring | --focus-ring-error |
| Disabled | --input, pointer-events: none | same |
The two colour families never cross. A destructive icon never hovers to a neutral fill; a normal icon never hovers to red.
Hover fill comes from --ck-icon-hover, which :root points at --hover-bg — the same hover fill every other ghost control in the product uses. It is set in one place deliberately, so every icon control agrees by construction.
Special Rules
Rules That Matter
- Lucide only — never hand-drawn, never a second library. If a glyph you need isn't in Lucide, draw it to Lucide's own contribution rules (below) so nobody can tell the difference.
- A 16px glyph in a 24px box, everywhere. One glyph size in the whole product. Lay out against the box, not the glyph — that's what makes a column of icons line up no matter what shape each glyph is.
- Two resting colours, and only two: normal and destructive. A third icon tone is a defect, not a variant — if an icon needs to carry another meaning, put the meaning in the label or a status pill.
stroke="currentColor", always. Hardcode a stroke colour and the icon stops following its container, its theme, and its hover state.- An interactive icon needs a real
<button>, a label, and a tooltip. An icon carries no text, so on its own it's unusable and unannounced. Use Icon Button — which is what an interactive icon almost always is.
Accessibility
- Interactive icons must be a real
<button>..ck-ico.is-interactivestyles a focusable element; it does not turn a<span>into a button. - Label the action, not the glyph:
aria-label="Delete Folder", not "Trash icon". Add a Tooltip with the same wording, shown on hover and focus. - Decorative icons get
aria-hidden="true"so they aren't announced twice alongside the label they sit next to. - A toggle needs
aria-pressed..is-selectedis visual only. - 20px is under WCAG 2.5.8's 24×24 target floor, so an interactive
.ck-iconeeds its target expanded — which.ck-icon-btn.size-xsalready does with a 24px::before. Prefer the button.
Don't
- Don't use
titleinstead of a tooltip. It doesn't appear on keyboard focus, can't be styled, and is announced unreliably. - Don't build a second tooltip. Use the kit's.
- Don't set a per-control glyph size.
- Don't convey status by icon colour alone — the two tones separate destructive from everything else, not one status from another.
Avatar
v3.2avatar.mdA tinted plate identifying a person, on the icon-box scale so it lines up with a glyph in the same column. Three modes and exactly one per plate: a photo, two initials, or a glyph where there is no person yet — an unclaimed seat, a system actor. Without that third mode call sites invent one, and the usual invention is a "?" that reads as an error rather than an absence.
<span class="ck-avatar">VJ</span>
<span class="ck-avatar"><img alt="Vaibhav Joshi" src="…"></span>
<span class="ck-avatar is-icon"><svg>…</svg></span>
<span class="ck-avatar-stack">
<span class="ck-avatar is-more">+4</span>
<span class="ck-avatar">RK</span>
<span class="ck-avatar">VJ</span>
</span>The stack is row-reverse, so the first avatar in the markup paints on top. Read order and paint order are opposites — put the overflow count first in the DOM and it lands last on screen.
Initials are two characters, uppercase. The class uppercases them; three or more overflow the plate at size-sm.
An image avatar carries alt — the person's name, unless their name already sits beside it, in which case the image is decorative and takes an empty alt.
Editor — the signed-in user's own photo
The signed-in user's own photo with the control that changes it. Distinct from .ck-avatar, which identifies someone in a list at 20–32px and is not editable. 104px is off the avatar scale on purpose — this is the one place a face is shown large enough to judge. The ring is --card, a cut-out of the surface beneath.

Button
kitbutton.mdThree variants plus link and destructive-ghost, three sizes, six states. The default IS the second-tier button — there is no separate grey secondary, and there should not be one: the outlined default was always doing that job, and shipping both meant every screen had to pick between two things that meant the same thing. The base is a --background surface carrying a --primary label and border, the recognised outline pairing (§ 3), at 12.01:1, and it hovers to --hover-bg. Nothing in the kit hovers to grey. Filled variants darken their own fill on hover via the --*-hover tokens; they must never use opacity, which dims the label with the fill and reads as disabled (§ 8). Button is the reference for the control height scale — 28 / 36 / 44, a uniform 8px step — so every other control was corrected to match it. The link variant lives here too: it is a .ck-btn variant, not a separate component, so it keeps the control height and aligns beside a filled button.
Holds a Value — .has-value
“Configured” is not “selected”. A control that opens a popover and comes back with something set was reaching for .is-selected because it was the nearest state — and that one means the control is the chosen one among several. .has-value is already the kit’s word for “this holds something” on fields; this is the same word for a control that is not a field. The signal is a dot, not a fill and not a stroke: the fill is selection’s, and .ck-btn is already --primary-bordered at rest so a stroke would say nothing. The two stack — the last one is selected and carries a value, which is a thing a control needs to be able to say. Compared on .ck-icon-btn because .ck-btn.is-selected carries no paint of its own.
Destructive outline — .ck-btn.destructive.is-outlined
A labelled destructive action that has to be findable at rest. The base .ck-btn already is the outline pattern, so this is that pattern in the destructive family and nothing more. Ghost gives a red word with no edge, which reads as a link in a row of buttons; the solid fill shouts.
Link variant — .ck-btn.link, all six states
Button
.ck-btn
What It Is
A labelled action. Icon-only actions use Icon Button instead.
Basic Information
Anatomy
<button class="ck-btn primary size-lg">
<svg>…</svg> <!-- optional leading icon -->
Add folder
</button>display: inline-flex with align-items: center and a gap, so an icon and a label share a baseline without any wrapper.
Variants
| Class | Surface | Label | Border | Use For |
|---|---|---|---|---|
| (none) — outline | --background | --primary | --primary | the default, and the second-tier button — any action beside a primary |
.primary | --primary | --primary-foreground | --primary | the one main action in a view |
.destructive | --destructive | --destructive-foreground | --destructive | delete, remove, disconnect |
.ghost | transparent | --foreground | transparent | toolbars, dense rows, tertiary actions |
.link | transparent | --link | transparent | a button-shaped link — see below |
The outline variant pairs a --background surface with a --primary label rather than --foreground. That's the outline pattern: legal because the label and the border are the same token, and primary on background measures 12.01:1.
Don't use .ghost as the main action — it has no affordance at rest.
Sizes
Button is the reference for the control height scale; the other controls were aligned to these numbers.
| Class | Height | Padding | Gap | Icon | Reach for it when |
|---|---|---|---|---|---|
.size-sm | 28px | 0 10px | 4px | 14px | the button repeats per row — table row actions, inline filter bars, a toolbar, a card footer in a dense grid. Also anything beside a .size-sm input |
| (none) | 36px | 0 14px | 6px | 15px | default — forms, dialog footers, page headers, field grids. If unsure, this |
.size-lg | 44px | 0 18px | 8px | 16px | one prominent action on a touch-first or sparse surface: auth screens, onboarding, an empty state's CTA. At most one per view |
Every height is a multiple of 4, stepping by a uniform 8px.
Type stays medium (500) / 13px at every size, so a row of mixed-size buttons shares one baseline and one weight. Only the box grows.
Geometry
| Property | Value |
|---|---|
| radius | --radius-md (8px), unchanged at every size |
| border width | 1px |
| transition | --duration-base on background, border-color, color |
white-space | nowrap — labels never wrap mid-button |
Link Variant — .ck-btn.link
A button-shaped link, and the only place it is documented — it is a fifth .ck-btn variant, not a component of its own, so its rules, states, sizes and tokens all live in this sheet. It's a fifth variant rather than a new component, and it keeps the control height scale, so it still aligns in a dialog footer beside a filled button.
| Slot | Token | Why |
|---|---|---|
| label | --link | exists precisely for this, and is distinct from --primary — a link is its own semantic |
| fill / border | transparent | the underline carries the affordance |
| active | --hover-bg | a press needs a surface, or the only feedback is the underline thickening |
| disabled | --muted-foreground | tokens, not opacity |
- Never remove the underline — colour alone is not an affordance.
text-underline-offset: 2px, thickening to 2px on hover. - It's declared after the
.ck-btnbase so it wins on source order. - It keeps the button's padding, so the hit area still clears 24×24.
Destructive Ghost — .ck-btn.ghost.destructive
A labelled destructive action with no fill: "Clear all", "Remove all filters", "Discard changes". This replaces the retired .ck-clear-btn, which was a whole component for what is one variant combination.
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--destructive | #e50600 | #fe9b98 | #ff4444 | rest · it announces what it does before being hovered, without a standing red fill |
--destructive-bg | #ffeaea | #3d3e41 | #330000 | hover · the soft pair, so the label stays at 4.5:1 on the tint |
--destructive-soft-foreground | #d90500 | #fe9b98 | #ff4444 | hover · the soft pair, so the label stays at 4.5:1 on the tint |
--destructive | #e50600 | #fe9b98 | #ff4444 | active · a firmer press, solid pair |
--destructive-foreground | #ffffff | #05262e | #000000 | active · a firmer press, solid pair |
--focus-ring-error | 0 0 0 3px #f9c8c7 | 0 0 0 3px #473d3f | 0 0 0 3px #380f0f | focus · destructive actions take the error ring |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | disabled · tokens, not opacity |
Specificity matters here. .ck-btn.ghost is declared after .ck-btn.destructive, so at equal specificity ghost wins and the pair rendered as a plain neutral ghost. These rules use two classes (0-3-0) to beat it — don't "fix" that by reordering the variants, which would break either variant used on its own.
It carries the same treatment as .ck-icon-btn.tone-danger, so a labelled and an unlabelled destructive action agree. A dismiss inside a pill is different and deliberately so — neutral at rest, destructive on hover. See Badge / Chip / Pill.
Layout — the glyph leads, always
<button class="ck-btn ghost destructive"><svg>…</svg>Clear All</button>The glyph comes first, then the label. Not the reverse, and never glyph-less. A trailing cross reads as dismiss this control; a leading cross reads as this action clears. The whole point of the variant is announcing what it does before it is hovered, and a trailing glyph undoes that.
This is the reference layout for every "Clear all" / "Reset" / "Remove all" in the kit — and both use it.
Tokens
States — every variant, including link
The link variant carries the same six states as the filled variants:
| State | Link Variant |
|---|---|
| rest | --link label, transparent fill, 1px underline at a 2px offset |
| hover | label stays --link; the underline thickens to 2px |
| active | --hover-bg fill behind the label, label stays --link |
| focus-visible | --focus-ring |
| disabled | --muted-foreground, with the underline in the same colour |
Hover changes the underline, not the colour. There is no --link-hover token — a link is already the emphasised thing in its row, so thickening the rule it already carries is a clearer signal than shifting its hue.
States
| State | Outline (default) | Primary | Destructive | Ghost |
|---|---|---|---|---|
| hover | --hover-bg | --primary-hover | --destructive-hover | --hover-bg + --primary label |
| active | --selected-bg | --primary-active | --destructive-active | --selected-bg |
| disabled | --muted surface, --muted-foreground label, --input border — identical for every variant | |||
| focus | box-shadow: var(--focus-ring); destructive uses --focus-ring-error |
Disabled matches on :disabled, .disabled and [aria-disabled="true"], so the class the reference pages use and the real attribute behave the same. It sets pointer-events: none so hover can't override it.
Hover and Active Fills
Derivation rule: a hover or active fill moves away from its own lightness extreme — a light surface darkens, a dark surface lightens — so there's always room to move. The result must still clear 4.5:1 against the family foreground; if it doesn't, it shifts the other way instead.
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--primary-hover | #26515f | #c3d2d6 | #55b7ca | #26515f · #c3d2d6 · #55b7ca |
--primary-active | #3e6370 | #a6b3b6 | #479bab | #3e6370 · #a6b3b6 · #479bab |
--destructive-hover | #c10400 | #d6827f | #d73838 | #c10400 · #d6827f · #d73838 |
--destructive-active | #a40300 | #ffb2ae | #ff776f | #a40300 · #ffb2ae · #ff776f |
Two values shift lighter rather than darker, because their family foreground is dark: dark --destructive-active and high-contrast --destructive-active. A first pass darkened those and high-contrast destructive-active measured 3.41:1.
Special Rules
Link, ghost and icon button are three different things
The distinction is fundamental, not cosmetic:
.ck-btn.linkcarries a text label with an underline. Use it for navigation or a tertiary action that should read as a link. The underline is the affordance — never remove it..ck-btn.ghostcarries a text label with no underline and no fill. Still a button, still performs an action in place.- Icon Button carries a glyph and no text, and needs an
aria-labelplus a tooltip.
A glyph-only action must never be .ck-btn.link. A labelled link must never be .ck-btn.ghost.
Rules That Matter
- One
.primaryper view. Two primaries compete and neither wins. - Size is density, not emphasis. Use
.primaryto make an action matter and.size-lgto fit a sparse or touch-first surface. A.size-lgdefault button in a dense table row gets both wrong. - Match the height of whatever it sits beside. A default button next to a
.size-sminput is the ragged row the control scale exists to prevent. - Let the token carry the hover — never
opacity. Opacity dims the label along with the fill, so the button reads as disabled. .destructiveis for irreversible actions, never for "Cancel".- Icon before label. The only exception is a trailing chevron.
- Native
<button>. Don't build one from a<div>. - There is no grey
secondaryvariant — the default is the second-tier button. It was retired because it duplicated the default's job, so every screen had to choose between two things that meant the same thing, and because its hover was the only grey hover in the kit:--secondary-hoverand--secondary-activeare gone with it. If you want an action to sit beside a primary, use the bare.ck-btn. - Nothing in the kit hovers to grey. Every hover is
--hover-bg, a--primary-step, a--destructive-step, or the sidebar accent. A grey hover reads as a different design system on the same page.
Accessibility
- The focus ring is
:focus-visible, so it appears for keyboard and not mouse. - Disabled uses
pointer-events: none. For a button that must stay focusable to explain why it's unavailable, usearia-disabled="true"and keep it in the tab order.
Known Defects
Both are token-set issues inherited by every component — don't patch them locally.
--muted-foregroundon--mutedis 3.16:1, under AA for body text. The disabled label inherits this in all five variants.- The focus ring fails the 3:1 non-text floor.
--focus-ringis a 25%-alpha tint of--ring, measuring 1.13:1 (light), 1.04:1 (dark) and ~1.2:1 (high contrast) against the page ground. It clears 3:1 against the button fill, but the ring's outer edge sits on the page, and that's what a keyboard user sees. Raising the alpha to 60% / 35% / 45% clears it.
Icon Button
kiticon-button.mdA button whose whole label is its glyph, so it always needs an aria-label and a tooltip. On the § 7a control scale — 28 / 36 / 44, square, a uniform 8px step — plus a 24px size-xs for controls that sit inside another control.
.size-2xs — 18px, the 12px glyph
Switched on — .is-on
On keeps the ground and tints the glyph. .is-selected fills the button, which is right for “this is the one you picked out of several”. It is wrong for a toggle that is simply on: a filled square in a row of unfilled ones reads as the current selection, so a bold button and an italic button both lit would look like a choice between them rather than two independent switches. aria-pressed is what makes it true; the class only paints it.
Icon Button
.ck-icon-btn
What It Is
A button whose entire label is its glyph. Used in toolbars, table rows, panel headers, and anywhere a text label wouldn't fit.
Basic Information
Anatomy
<button class="ck-icon-btn" aria-label="Delete Folder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">…</svg>
</button>Sizes
On the control height scale. Square, so width tracks height.
| Class | Box | Glyph | Reach for it when |
|---|---|---|---|
.size-xs | 24px | 16px | a control inside another control: a chip's dismiss, a dialog's close, an inline remove, a search box's clear. 24px is the target-size floor — don't go below it |
.size-sm | 28px | 16px | the button repeats per row — table row actions, an inline toolbar, a compact panel header. Also whenever it sits beside a .size-sm input or button |
| (none) | 36px | 16px | default — page headers, dialog headers, standard toolbars |
.size-lg | 44px | 16px | touch-first surfaces, or a lone control with real prominence. Rare in product UI. 44px is the AAA target size |
The glyph is a flat 16px at every size — .ck-icon-btn svg reads --icon-size, with no per-size override. The box grows; the glyph does not. That is the icon law applying here like everywhere else.
.size-xs is deliberately off the control scale. It's never a peer of a button or an input — it's a control nested inside one. In a toolbar row it would sit 10px short of its neighbours.
.sm and .lg survive as aliases because pages already use them. New markup uses .size-sm / .size-lg.
Tokens
Tones
Tone is two local properties rather than a variant class per colour, so adding one is two declarations and no new rules:
| Class | Hover Fill | Hover Glyph | Use For |
|---|---|---|---|
| (none) | --hover-bg | --foreground | neutral actions |
.tone-accent | --hover-bg | --primary | the affirmative action in a group |
.tone-danger | --destructive-bg | --destructive-soft-foreground | delete, remove, disconnect |
.tone-primary | --primary | --primary-foreground | a single emphasised control |
.on-dark | --toolbar-dark-hover | --toolbar-dark-foreground | inside a --toolbar-dark surface |
/* a new tone, in full */
.ck-icon-btn.tone-warning{--ck-icon-hover:var(--warning-bg);--ck-icon-hover-fg:var(--warning)}.tone-danger is destructive at rest
A cross, a trash, a disconnect reads destructive at rest — not neutral with a red hover. All six states:
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--icon-color-destructive | #d90500 | #fe9b98 | #ff4444 | rest |
--destructive-soft-foreground | #d90500 | #fe9b98 | #ff4444 | hover |
--destructive-bg | #ffeaea | #3d3e41 | #330000 | hover |
--destructive | #e50600 | #fe9b98 | #ff4444 | active |
--destructive-foreground | #ffffff | #05262e | #000000 | active |
--destructive-soft-foreground | #d90500 | #fe9b98 | #ff4444 | selected |
--destructive-bg | #ffeaea | #3d3e41 | #330000 | selected |
--focus-ring-error | 0 0 0 3px #f9c8c7 | 0 0 0 3px #473d3f | 0 0 0 3px #380f0f | focus |
--input | #8b9292 | #5f7073 | #999999 | disabled |
.ck-alert's dismiss shares this rule rather than duplicating the values.
| Part | Token |
|---|---|
| resting glyph | --muted-foreground |
| resting fill | transparent |
| hover | --ck-icon-hover / --ck-icon-hover-fg (set by tone) |
| active | --selected-bg |
| selected | --selected-bg + --selected-fg |
| disabled glyph | --input |
| focus | --focus-ring |
| radius | --radius-md; .size-xs is --radius-sm |
The hover fill defaults to --hover-bg — the product-wide hover fill for any ghost control. It's set once in :root as --ck-icon-hover, so every icon control in the product agrees by construction. That matters: it used to be overridden locally here, so an icon button hovered blue while the header bar's .icon-btn, which had no override, hovered grey.
--ck-icon-hover and --ck-icon-hover-fg are component-scoped custom properties, not theme tokens — set per tone on the element, not per theme in :root.
The hover fill is a rounded square, never a circle — --radius-md, or --radius-sm at .size-xs so a 24px box doesn't read as a circle.
States
| State | Treatment |
|---|---|
| default | transparent, --muted-foreground glyph |
| hover | tone's fill and glyph |
| active | --selected-bg |
selected (.is-selected) | --selected-bg + --selected-fg — a persistent state, e.g. an active view toggle |
| disabled | --input glyph, transparent, pointer-events: none |
| focus | --focus-ring on :focus-visible |
Disabled uses a token rather than opacity, so the glyph keeps its contrast rather than fading into the surface.
Rendering the states in a spec sheet
data-force~="hover|active|focus|disabled" paints a state without the user being in it, so a documentation page can show all six from the kit's own rules rather than re-declaring them in page CSS. Documentation only — never in product markup, where a forced state is a lie about what the control is doing.
Special Rules
Not to be confused with a link button
Three labelled-or-not actions get mixed up. The difference is fundamental, not cosmetic:
| Carries | Use For | |
|---|---|---|
| icon button | a glyph, no text | an action with no room for a label — needs aria-label + a tooltip |
.ck-btn.link | a text label with an underline | navigation, or a tertiary action that reads as a link |
.ck-btn.ghost | a text label, no underline, no fill | a tertiary action that is still a button |
A glyph-only action must never be .ck-btn.link, and a labelled link must never be .ck-btn.ghost — the underline is what tells a reader it navigates.
Outlined — .is-outlined
A bordered icon control, for a run of them that must read as a group against a busy row — Pagination's first / prev / next / last.
| State | Treatment |
|---|---|
| rest | --background fill, --input border, --muted-foreground glyph |
| hover | --ck-icon-hover fill, --input-border-hover border |
| active | --selected-bg fill, --primary border |
| disabled | --muted fill, --muted-foreground glyph, --input border |
It takes the field stroke set, so it stays in sync with .ck-input and .ck-dd-trigger — a footer's nav buttons and its rows-per-page chooser sit in the same row and must carry the same edge.
The resting pair is a declared exemption: --muted-foreground on --background is 3.44:1, the same de-emphasis Empty State carries. A run of four chevrons must read quieter than the data above it.
The pill dismiss is the exception
Inside a .ck-pill / .ck-badge / .ck-chip, the dismiss cross does not use a tone. It rests in the pill's own foreground and only shows destructive tokens on hover — a cross that's red at rest reads as a standing warning on every chip in a filter bar.
So .tone-danger is wrong there. See Badge / Chip / Pill for the full state table and the three specificity traps involved.
Rules That Matter
aria-labelis not optional. There's no text, so without it the button announces as "button" and nothing else. This is the single most common defect in this component. Label the action, not the icon: "Delete folder", not "Trash icon".stroke="currentColor"on the glyph, or the tone system can't recolour it.- Match the neighbour's size. A 36px icon button beside a 28px input is the failure this component's size scale exists to prevent.
.tone-dangermeans destructive — not "close" or "cancel."- A toggle needs
aria-pressed..is-selectedis visual only.
Accessibility
.size-lgis exactly 44×44, the AAA target size..size-smat 28px clears the 24×24 AA floor. Don't go below 28px except for.size-xsnested controls at 24px.- The focus ring is
:focus-visible, so it appears for keyboard and not mouse.
Merged One-Offs
Two bespoke buttons duplicated this component and are now aliases of it:
| Was | Now | What it gained |
|---|---|---|
.ck-remove — 16px, --destructive, own hover | .ck-icon-btn.tone-danger.size-xs | 8px of hit area (16px failed the 24×24 floor), a disabled state, an active state |
.ck-dialog-x — 24px, own hover | .ck-icon-btn.size-xs | a disabled state, an active state, the tone system |
New markup uses .ck-icon-btn directly. The aliases exist so that if the classes turn up in an unsearched flow, it still renders.
Notes
Sizes were 26 / 32 / 38, which left an icon button 2px short of the button beside it at every step. They're now 28 / 36 / 44, matching .ck-btn, .ck-input, .ck-select and .ck-dd.
Some non-ck--prefixed siblings (.nav-btn, .theme-opt) share its rules. That's flagged, not fixed — it needs triage.
Checkbox
kitcheckbox.md16px to match Radio, an --input border at rest and a --primary fill with a --primary-foreground mark when checked. Both are bare outlines at rest, which is why § 9 binds on their border.
Sizes — 16px default · 12px .size-sm, the table checkbox
Same states, same interaction. Every checkbox inside a .ck-table is 12px by default; the target stays 24px.<label>, so the browser forwards a click on the text to the control — one target, no script, nothing that can desync. The label follows the control’s state: disabled greys it, invalid turns it destructive.Checkbox
.ck-check
What It Is
For a value that applies on submit. For a setting that takes effect immediately, use Switch.
Basic Information
Anatomy
<input type="checkbox" class="ck-check">
<input type="checkbox" class="ck-check" checked>
<input type="checkbox" class="ck-check" aria-invalid="true">The tick and the indeterminate dash are drawn on ::after — no icon font, no SVG, no wrapper.
Geometry
| Property | Value | Note |
|---|---|---|
| size | 16px | matches .ck-radio and shadcn's size-4. It was 14px, so a checkbox and a radio in the same form were visibly different sizes |
| border | 1.5px | |
| radius | --radius-xs (4px) | at 2px a 16px box reads as a rendering artefact rather than a deliberate corner. 4px is also what the React kit already shipped |
| tick | 3.5 × 7px, rotated 45° | two borders with a 1px radius, so the elbow and both ends are rounded — the round joins and caps § 7c asks of every icon. Centred with left/top: 50% and a -50% translate |
| dash | 7 × 1.5px | a filled bar with --radius-full, so both ends are round. It was a border-top, which cannot take a radius |
Sizes
None. 16px, fixed.
shadcn ships no size variants either, and the reason holds here: a checkbox is read against its label, not against neighbouring controls, so it has no row-height obligation to meet. What it does have to match is .ck-radio, and it does.
If a dense table needs a smaller target, shrink the row, not the box. Below about 14px the tick stops being legible and the hit area fails the target-size floor. Keep the box and let the <label> carry the hit area.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--input | #8b9292 | #5f7073 | #999999 | resting border · border-input |
--background | #ffffff | #142226 | #000000 | resting fill · (light default) |
--primary | #013c4b | #e7f9fe | #66d9ef | checked fill · bg-primary |
--primary | #013c4b | #e7f9fe | #66d9ef | checked border · border-primary |
--primary-foreground | #ffffff | #05262e | #000000 | tick / dash · text-primary-foreground |
--primary | #013c4b | #e7f9fe | #66d9ef | hover border · — |
--primary-hover | #26515f | #c3d2d6 | #55b7ca | checked hover fill · (shadcn has none) |
--primary-active | #3e6370 | #a6b3b6 | #479bab | checked active fill · (shadcn has none) |
--destructive | #e50600 | #fe9b98 | #ff4444 | invalid border · aria-invalid:border-destructive |
--focus-ring-error | 0 0 0 3px #f9c8c7 | 0 0 0 3px #473d3f | 0 0 0 3px #380f0f | invalid focus ring · ring-destructive/20 |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus ring · ring-ring/50 |
--muted | #f5f5f5 | #1b292d | #1a1a1a | disabled fill · (shadcn uses opacity-50) |
--input | #8b9292 | #5f7073 | #999999 | disabled checked fill · — |
--background | #ffffff | #142226 | #000000 | disabled mark · — |
One deliberate divergence from shadcn. shadcn disables with opacity-50, which fades the tick along with the box. We use tokens instead, so the tick keeps its contrast on a dimmer box — with opacity the mark loses contrast and the control reads as broken rather than unavailable.
One divergence we don't take. shadcn tints the unchecked fill in dark mode (dark:bg-input/30) because a bare border on a dark ground is weak. We rely on the corrected --input border instead, which measures 3.15:1 against the dark page — perceivable on its own, so no tint token is needed.
States
| State | Unchecked | Checked / Indeterminate |
|---|---|---|
| default | --input border on --background | --primary fill, --primary-foreground mark |
| hover | --primary border | --primary-hover fill |
| active | --primary border, --hover-bg fill | --primary-active fill |
| focus | --focus-ring | --focus-ring |
| invalid | --destructive border, --focus-ring-error on focus | same, fill unchanged |
| disabled | --muted fill, --input border | --input fill, --background mark |
Hover has to change the fill once checked — moving only the border is invisible on a filled box.
Disabled sets pointer-events: none so hover can't override it, and matches :disabled as well as [aria-disabled="true"].
Contrast
| Check | Light | Dark | High Contrast |
|---|---|---|---|
| resting border vs page | 3.17 | 3.15 | 7.37 |
| checked fill vs page | 12.01 | 15.06 | 12.74 |
| tick on checked fill | 12.01 | 15.06 | 12.74 |
| invalid border vs page | 4.82 | 8.05 | 6.16 |
| disabled mark on fill | 3.17 | 3.15 | 7.37 |
An unchecked checkbox is a bare outline, so the 3:1 non-text floor binds on its border the same way it binds on a Switch track. This is the second component depending on the corrected --input.
Special Rules
Rules That Matter
- Use the native
<input type="checkbox">withappearance: none. Not a styled<div>withrole="checkbox". indeterminateis a DOM property, not an attribute. Set it in script —el.indeterminate = true.indeterminate="true"in markup does nothing, and the DOM property is also what makes it announce as "mixed".- It needs an accessible name. Wrap it in a
<label>or usefor/aria-label. A checkbox with no name is a defect however it looks. aria-invalid="true"is what assistive tech reads. A red border alone conveys nothing to a screen reader.- There are no size variants, and that's deliberate — see below.
Accessibility
- Space toggles, for free, from the native input.
- 16px is under the 44×44 AAA recommendation but clears the 24×24 AA floor when the label is part of the hit area — so wrap it in a
<label>. - State is carried by fill and mark, not colour alone.
#### Both glyphs are rounded and centred
Rounded. A CSS border cannot take stroke-linecap, and a data-URI SVG would hardcode a colour and stop following the theme — so the tick is two borders carrying a 1px border-radius, which rounds the elbow where they meet and both open ends. The minus is drawn as a filled bar rather than a border-top, because a border has square caps and cannot be rounded at all.
Centred. Both sit at left: 50%; top: 50% with a translate(-50%, -50%). Note those percentages resolve against the padding box, not the border box — 7px inside a 16px box with a 1px border — which is still the true centre, because the padding box is inset equally on every side. The tick previously carried an optical nudge to 45%; it is exact geometry now.
Notes
The 4px radius comes from the --radius-xs token, not from the component, so the rest of the mark tier follows it: the search-highlight <mark> in the dropdown and menu, and .ck-tip code. There's no 4px literal anywhere.
Radio is unaffected — it's --radius-full, being a circle.
Radio
kitradio.mdSame 16px box as Checkbox so the two line up in a shared form. Selection is a --primary ring plus a --primary dot.
Radio
.ck-radio
What It Is
One choice from a set of two or more mutually exclusive options, all visible at once.
Basic Information
Anatomy
<div class="ck-radio-set">
<span class="ck-radio-legend">Match strategy</span>
<div class="ck-radio-row">
<label class="ck-radio"><input type="radio" name="strategy" checked><span>Exact</span></label>
<label class="ck-radio"><input type="radio" name="strategy"><span>Fuzzy</span></label>
</div>
</div>The dot is drawn on the input's ::after.
| Class | Role |
|---|---|
.ck-radio-set | vertical group, 10px gap; consecutive sets get 20px between them |
.ck-radio-legend | group label, 500 / 13px in --foreground |
.ck-radio-row | horizontal option row, 28px gap, wraps |
.ck-radio | one option — the label wrapper |
Geometry
| Property | Value |
|---|---|
| size | 16px, matching .ck-check and shadcn size-4 |
| border | 1.5px |
| radius | --radius-full |
| dot | inset: 3px — so 10px across, and it scales with the control |
| label gap | 8px |
| label type | 400 / 13px |
The dot is positioned by inset rather than a fixed width, so changing the control size needs no second value — the same reasoning as the Switch's derived travel.
Sizes
None. 16px, fixed, matching Checkbox — a radio is read against its label rather than against neighbouring controls.
Density in a radio set comes from the wrappers, not the control. .ck-radio-set sets the vertical gap (10px), .ck-radio-row the horizontal one (28px). Tighten those for a dense form; leave the 16px control alone.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--input | #8b9292 | #5f7073 | #999999 | resting border · border-input |
--background | #ffffff | #142226 | #000000 | resting fill · (light default) |
--primary | #013c4b | #e7f9fe | #66d9ef | checked border · border-primary |
--primary | #013c4b | #e7f9fe | #66d9ef | dot · fill-primary |
--primary | #013c4b | #e7f9fe | #66d9ef | hover border · — |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | active fill · — |
--primary-hover | #26515f | #c3d2d6 | #55b7ca | checked hover dot · (shadcn has none) |
--primary-active | #3e6370 | #a6b3b6 | #479bab | checked active dot · (shadcn has none) |
--destructive | #e50600 | #fe9b98 | #ff4444 | invalid border · aria-invalid:border-destructive |
--destructive | #e50600 | #fe9b98 | #ff4444 | invalid dot · — |
--focus-ring-error | 0 0 0 3px #f9c8c7 | 0 0 0 3px #473d3f | 0 0 0 3px #380f0f | invalid focus ring · ring-destructive/20 |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus ring · ring-ring/50 |
--muted | #f5f5f5 | #1b292d | #1a1a1a | disabled fill · (shadcn uses opacity-50) |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | disabled dot · — |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | disabled label · — |
Same divergence from shadcn as Checkbox: tokens rather than opacity-50, so the dot keeps its contrast on a dimmer control.
A checked radio keeps a --primary border and fills only the inner dot — it never fills the whole circle. That's the platform convention, and it's what distinguishes a radio from a checkbox at a glance.
States
| State | Unchecked | Checked |
|---|---|---|
| default | --input border | --primary border, --primary dot |
| hover | --primary border | --primary-hover dot |
| active | --hover-bg fill | --primary-active dot |
| focus | --focus-ring | --focus-ring |
| invalid | --destructive border | --destructive border and dot |
| disabled | --muted fill, muted label | --input border, --muted-foreground dot, muted label |
Hover has to move the dot once checked — the border is already --primary when checked, so moving only the border changes nothing.
Disabled uses :has(input:disabled) on the wrapper so the label mutes together with the control, plus .is-disabled as a fallback.
Contrast
| Check | Light | Dark | High Contrast |
|---|---|---|---|
| resting border vs page | 3.17 | 3.15 | 7.37 |
| checked border vs page | 12.01 | 15.06 | 12.74 |
| dot on resting fill | 12.01 | 15.06 | 12.74 |
| invalid border vs page | 4.82 | 8.05 | 6.16 |
| disabled dot on fill | 3.16 | 9.92 | 13.18 |
| label vs page | 15.87 | 16.32 | 21.00 |
A radio is a bare ring at rest, so the 3:1 non-text floor binds on its border — the third component depending on the corrected --input.
Special Rules
Rules That Matter
- The class goes on a wrapping
<label>, not on the input. That's what makes the text part of the hit area and lets the whole row go muted when disabled — and it's what carries the 16px control past the 24×24 target floor. - One
nameper logical group. Sharing a name across what should be separate groups makes them one group, so only one can ever be checked. Easy to do accidentally when generating markup. - A single radio is never right — use a Checkbox. Past about seven options, use a Searchable Dropdown.
- The group needs a real name.
.ck-radio-legendis visual only — use a<fieldset>+<legend>, orrole="radiogroup"witharia-labelledbypointing at the legend. - Preselect a sensible default. Don't leave a required group with nothing checked.
Accessibility
- Arrow keys move within the group and check as they go; Tab enters and leaves the group as a single stop. All native.
aria-invalid="true"belongs on the inputs — the red ring alone conveys nothing to a screen reader.- State is carried by the dot's presence, not by colour alone.
Notes
There's a trap when building a test harness for this: give every radio cell its own name. Reusing names across theme blocks silently merges them into one group, so only one cell renders checked.
Switch
kitswitcher.mdshadcn name Switch, Clipper legacy name Switcher. It commits a binary choice the moment it is flipped — there is no Save step. The canonical class is .switch and state is expressed the shadcn way with data-state="checked" / "unchecked", which is what the React kit's switch.tsx keys on, so the two halves of the kit now agree. The legacy .ck-switch class, the .on flag and aria-checked are all still honoured, so shipped flows keep rendering. Geometry is --switch-w, --switch-h, --switch-thumb and thumb travel is derived from them, so a size variant is a token change and never a new rule.
sm · 32 × 20thumb 12pxdefault · 40 × 24thumb 16pxlg · 48 × 28thumb 20px<button role="switch"> rather than an input, and a button is a labelable element, so label.control still resolves to it and the click still forwards. .is-reversed gives the settings-row reading: the name on the left, the control lined up on the right.Switcher
.switch · .ck-switch
What It Is
A toggle for a setting that takes effect immediately. For a value that only applies on submit, use a Checkbox.
shadcn calls this component Switch; the Clipper legacy name is Switcher.
Basic Information
Anatomy
<button class="switch" role="switch" aria-checked="false" data-state="unchecked"></button>State is expressed the shadcn way, with data-state="checked" / "unchecked". aria-checked is also required — data-state drives the paint, aria-checked drives the announcement, and a <button> with neither announces no state at all.
.ck-switch and .on are honoured for markup already shipped; new markup uses .switch and data-state.
Geometry
Four local custom properties — --switch-w, --switch-h, --switch-thumb, --switch-inset — and the thumb's travel is derived from them (w − thumb − 2·inset). So a size variant is a token change and never a new rule, and the travel can't fall out of sync with the geometry.
| Class | Track | Thumb | Inset | Travel | Reach for it when |
|---|---|---|---|---|---|
.size-sm | 32 × 20 | 12px | 4px | 12px | one switch per row in a dense list — a settings table, a permissions grid, a column-visibility menu |
| (none) | 40 × 24 | 16px | 4px | 16px | default — settings panels, forms, .ck-switch-block rows with a title and description |
.size-lg | 48 × 28 | 20px | 4px | 20px | touch-first surfaces, or a single headline toggle that's the point of the screen. Rare |
The inset is 4px at every size, so a small switch looks smaller, not tighter. Every number here is a multiple of 4.
shadcn ships one size; sm and lg are Clipper extensions.
Radius is --radius-full on both track and thumb.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--toggle-track | #a9b6b7 | #4a5c5f | #666666 | the OFF track |
--toggle-track-hover | #95a0a1 | #637375 | #7b7b7b | OFF track, hover and pressed |
--primary | #013c4b | #e7f9fe | #66d9ef | the ON track |
--primary-hover | #26515f | #c3d2d6 | #55b7ca | ON track, hover |
--primary-active | #3e6370 | #a6b3b6 | #479bab | ON track, pressed |
--background | #ffffff | #142226 | #000000 | the knob, in every state |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus |
--radius-full | 999px | — | — | track and knob |
| Part | OFF | ON |
|---|---|---|
| track | --toggle-track | --primary |
| track, hover | --toggle-track-hover | --primary-hover |
| track, pressed | --toggle-track-hover | --primary-active |
| knob | --background | --background |
| disabled | the same colours at --disabled-opacity | the same colours at --disabled-opacity |
The OFF track has its own token
--toggle-track is #a9b6b7 in light — the v2.0 kit's --switcher-off, brought back because it is the switch the product's users already recognise.
It is not --input, and that matters in both directions. --input is the border of every field and the secondary button; it is #8b9292, darker, and it has to stay there to hold those strokes at 3:1. And --track is no use either — that is the rail behind a fill on a progress bar, at #d7dddf, paler still.
So the switch gets its own value, in all three themes. This is not a component-named token in the sense the kit forbids — --switcher-on was retired because it was literally var(--primary), an alias carrying no information. --toggle-track carries a value nothing else has.
#### The knob is white, in every state
It is --background throughout — OFF, ON, hover, pressed, focus and disabled. Nothing recolours it.
#### Disabled is a faded switch, not a different one
The whole control drops to --disabled-opacity (0.4). The track keeps its real colour and the knob keeps its white, so a disabled switch is visibly the same switch, just out of play. This renders pixel-identical to the v2.0 kit: #dce1e2 track OFF, #98b0b6 track ON, white knob, in light.
This is the kit's one opacity-based disabled state, and it is deliberate. The rule against opacity for disabled exists because opacity dims a control's label along with its box, so the words read as a rendering fault. A switch has no label and no glyph — it is a track and a circle. There is nothing for opacity to damage, and fading is precisely the message.
Re-colouring was tried instead: a --muted track with a --muted-foreground knob. It measured better at the time but it changed what the control was rather than dimming it, so disabled-OFF and disabled-ON stopped reading as one component in two states.
The knob follows the theme's --background, and that is deliberate. In dark theme --primary is near-white (#e7f9fe), so a knob forced to white would measure 1.08:1 on a checked switch — invisible. The knob inverts with the theme precisely so it always reads against its track.
Contrast
An unchecked switch has no label inside it, so the track is the entire affordance and the 3:1 non-text floor applies to it.
| Check | Light | Dark | High Contrast |
|---|---|---|---|
| OFF track vs page | 2.09 | 2.32 | 3.66 |
| ON track vs page | 12.01 | 15.06 | 12.74 |
| knob vs OFF track | 2.09 | 2.32 | 3.66 |
| knob vs ON track | 12.01 | 15.06 | 12.74 |
The OFF track is under 3:1 in light and dark, and that is a stated choice rather than an oversight. --toggle-track is the switch the product already ships and the one its users recognise; matching it was the requirement. The earlier --input track measured 3.17 / 3.15 / 7.37 and cleared the floor, so the trade is recorded here: recognisability against 1.08 points of contrast on a control whose state is also carried by knob position, which is not a colour signal at all.
If the floor has to be met later, the fix is a darker --toggle-track — not a different mechanism.
Disabled tracks sit lower again, and that part is uncontroversial: WCAG exempts inactive components.
This component is why --input was corrected, back when the switch read from it. It measured 1.52:1 in light and 1.36:1 in dark — under the floor in two of three themes. That correction stands and still matters: --input is the border of .ck-input, .ck-textarea, .ck-dd-trigger, the secondary button and every outline-style control, all of which gained a perceivable boundary they did not have. The switch has since moved to its own --toggle-track, but the fix it prompted is load-bearing everywhere else.
Special Rules
Rules That Matter
role="switch"andaria-checkedare required, not optional. A bare<button>announces as "button" with no state, so a screen reader user can't tell on from off.data-statedrives the paint;aria-checkeddrives the announcement. Set both. The visual keys off the attribute precisely so it can't drift from what's announced.- A switch is not on the 28/36/44 control scale, deliberately. It's a fixed-ratio track, so stretching it to a 36px control height would make it a slab. It aligns within a row — centre it against the row's text — rather than setting the row's height.
- The label goes beside the switch, never inside the track. Wrap it in
.ck-switch-rowor.ck-switch-block, or give the switch anaria-label. A switch with no accessible name is a defect however it looks. - Thumb position carries the state in every theme, so it never depends on colour alone.
- The knob sits inside the track with room to spare — a 4px inset at every size, so 16px of knob in a 24px track. At 20px the knob filled the track and read as a disc jammed into a slot rather than a switch.
- Disabled is drained, not blanked. OFF fills the knob with
--muted-foregroundon the--mutedtrack; ON keeps the white knob because its track is the much darker--input(3.17:1). A white knob on the pale OFF track measures 1.09:1 and reads as an empty outline, which is why this one state does not use the white knob. - The universal checks apply here like anywhere else. Four of them bite on this component in particular: check 5 (disabled is drained but legible — and this is the one control allowed to use
--disabled-opacityto do it), check 8 (a switch on a line with a field or a button shares that row's height, and every height on the scale is even), check 26 (the knob is optically centred, and a row of switches lines up with itself), and check 28 (a switch never sets its own row's width — it isflex-shrink: 0and the label beside it takes the slack).
Accessibility
- Native
<button type="button">, so Space and Enter both toggle for free. - The hit area is 32×20 at the smallest — under the 44×44 AAA recommendation though it clears the 24×24 AA floor. In dense rows, give the row itself a larger click target.
Don't
- Don't use a styled
<div>. - Don't toggle
.onand forget the attribute. - Don't invent a fourth size.
data-force — documentation only
data-force~="hover|active|focus|disabled" paints a state without the user being in it, so a spec page can render the full 2 values × 5 states × 3 modes matrix from the kit's own rules rather than re-declaring them in page CSS, which goes stale.
Never in product markup. A forced state is a lie about what the control is doing.
All three themes render side by side on the showcase rather than behind the theme toggle — a mode you have to toggle to is a mode nobody checks. [data-theme] is a plain selector, not a media query, so a subtree can carry its own mode.
Slider
v3.2slider.mdA real <input type="range"> so arrow keys, Home/End and PageUp/Down work (§ 14.9 check 11). The fill is a gradient at --ck-slider-pct, kept in step by ckSlider().
Positions
Slider
.ck-slider
What It Is
A single-value range input.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-slider | the input |
.ck-slider-row | the input with a value readout beside it |
.ck-slider-val | monospace tabular readout |
Sizes
None.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--track | #d7dddf | #2b4045 | #2b2b2b | the rail — the unfilled remainder, shared with Progress |
--primary | #013c4b | #e7f9fe | #66d9ef | fill |
--background | #ffffff | #142226 | #000000 | thumb |
--primary | #013c4b | #e7f9fe | #66d9ef | thumb |
--shadow-toggle | — | — | — | thumb lift |
States
| State | Treatment |
|---|---|
| rest | as above |
| hover | --primary-hover |
| active | --primary-active |
| focus-visible | --focus-ring on the thumb |
| invalid | --destructive + --focus-ring-error |
| disabled | --input filled portion + --track remainder; thumb goes --muted |
The disabled state keeps the fill. Flattening the whole rail to one grey threw the position away, so a disabled slider stopped saying what it was set to.
Special Rules
The rail must not camouflage into its container
The rail reads --track, the same token as a Progress bar's unfilled remainder. It used to read --muted, which measures 1.00:1 against a --muted panel and 1.04:1 against --canvas — on either surface the rail simply was not there, and the control read as a floating thumb.
Check the rail against the panel it actually sits on, not against white.
Rules That Matter
- Use the native
<input type="range">. Not a<div>with a drag handler. The native control announces as a slider and gives you arrow keys, Home/End and PageUp/PageDown for free. - The affordance is the thumb and the filled portion, both
--primary. The unfilled remainder is a container, so it's--muted. - Focus goes on the thumb, not the rail.
- A slider accepts input; a Progress bar reports.
Accessibility
aria-labelor a visible<label>. Pointaria-describedbyat the.ck-slider-valso the readout is announced.- The thumb and the control are 20px — the slider's own geometry, recorded here as § Law 2 requires. It used to read
--icon-box; when that grew to 24px the thumb would have stayed 20 and the rail centring would have drifted 2px, so the slider now states its height itself. The whole control height is the target.
Controller
ckSlider() in clipper-kit.js?v=3ce65335 keeps --ck-slider-pct in step with the value and mirrors it into .ck-slider-val. It's idempotent, so it's safe to call again after inserting markup.
WebKit has no ::-webkit-slider-progress, which is why the fill is a gradient rather than a pseudo-element. If the fill looks missing, the page probably isn't loading clipper-kit.js?v=3ce65335.
Notes
The 20px thumb is deliberately larger than --icon-size — it reads as a grabbable control rather than a glyph.
The rail's colour is a declared deviation from the rulebook's "slider rail" wording, stated here so the next reader can weigh it rather than discover it.
Progress
v3.2progress.mdThe affordance is the FILL, which carries the contrast; the track is only its container. The track has its own token as of v3.2 — --progress — because --muted measured 1.04:1 against --canvas, so a bar on the app ground was invisible. Warning inherits the documented 2.05:1 token defect.
Fill Levels
Semantic Tones at 60%
Count Variant — .is-count
measured in files, not percent. The fill is derived from the two countsProgress
.ck-progress
What It Is
A determinate bar for work with a known length, plus .is-indeterminate for work without one.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-progress | the track |
.ck-progress-bar | the fill; width from --ck-prog-val |
.is-success / .is-warning / .is-error | fill tone |
.is-indeterminate | a 30% fill sliding the track |
.ck-progress-row | the bar with a meta line above it |
.ck-progress-meta | label left, value right |
.ck-progress-val | tabular numerals |
The Count Variant — .is-count
For work measured in things, not percent: "3 of 12 files uploaded".
<div class="ck-progress-row">
<div class="ck-progress-meta">
<span>Uploading files</span>
<span class="ck-progress-count"><strong>3</strong> of 12 files</span>
</div>
<div class="ck-progress is-count" role="progressbar"
aria-valuemin="0" aria-valuemax="12" aria-valuenow="3"
aria-valuetext="3 of 12 files uploaded"
style="--ck-prog-done:3;--ck-prog-total:12">
<div class="ck-progress-bar"></div>
</div>
</div>| Property | Meaning |
|---|---|
--ck-prog-done | how many are finished |
--ck-prog-total | how many there are |
--ck-prog-failed | how many failed, if any |
The fill width is derived from the counts, not passed in separately. The component computes done / total, so the bar and the readout cannot disagree — the same reasoning as the switch's derived thumb travel. There is no percentage anywhere in the markup.
A failed segment abuts the completed one on the same track, so done, failed and not-yet-attempted read as one bar rather than three indicators:
<div class="ck-progress is-count" role="progressbar"
aria-valuemin="0" aria-valuemax="12" aria-valuenow="9"
aria-valuetext="9 of 12 files uploaded, 1 failed"
style="--ck-prog-done:9;--ck-prog-total:12;--ck-prog-failed:1">
<div class="ck-progress-bar"></div>
<div class="ck-progress-fail"></div>
</div>Where both segments are present their inner edges square off, so the pair reads as one continuous bar; the track's own clip rounds the outer ends.
| Class | Role |
|---|---|
.ck-progress.is-count | the count-driven track |
.ck-progress-fail | the failed segment |
.ck-progress-count | the readout. <strong> carries the done number; .is-failed tints a failure count |
Sizes
.size-sm 4px · default 8px · .size-lg 12px — track thickness.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--track | #d7dddf | #2b4045 | #2b2b2b | the unfilled remainder of the track — shared with Slider |
--primary | #013c4b | #e7f9fe | #66d9ef | the completed fill |
--success | #2e9e52 | #42c070 | #44ff88 | completed fill, .is-success |
--warning | #f2a618 | #f7b83d | #ffbb33 | completed fill, .is-warning |
--destructive | #e50600 | #fe9b98 | #ff4444 | completed fill .is-error, and the failed segment of a count bar |
--foreground | #05262e | #ffffff | #ffffff | the done number in a count readout |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | the total and the noun in a count readout |
The track has its own token as of v3.2. It used to read --muted, which measures 1.04:1 against --canvas and 1.00:1 against a --muted panel — a bar laid on either was invisible. --track is 1.21 against the canvas, 1.37 against a card and 1.26 against a muted panel, and the --primary fill still dominates it at 8.75:1.
--track is named for the role, not the component. It serves the progress bar and the Slider rail, so a --progress token would have been the component-named redundancy that retired --switcher-on.
It is a bare non-text token, alongside --border, --input and --ring: a track carries no text, so it has no -foreground partner. The filled portion stays on the semantic tones rather than being duplicated into a --track-foreground.
States
No interaction states — it isn't a control.
Under prefers-reduced-motion the indeterminate animation is slowed to 3.6s and the width transition removed, not dropped.
Special Rules
A determinate bar must actually move
If the work is running, the value has to change. A bar parked at one value reads as a hung process, and it is worse than no bar at all.
- Length known → update
--ck-prog-val, or the two counts, as work completes. The component transitions the width for you, so the flow only has to set the number. - Length unknown → use
.is-indeterminate. Do not fake motion by animating a determinate bar to a value you have not reached.
The track must not camouflage into its container
The track reads --track for exactly this reason. Check it against the panel it sits on — --muted measured 1.00:1 on a muted panel and 1.04:1 on --canvas, which is invisible.
A count bar must carry aria-valuetext
Without it, assistive tech computes a percentage from aria-valuemin / aria-valuemax and announces "25%" — the wrong unit for the thing being measured, and the reason this variant exists at all. Write what a person would say: aria-valuetext="3 of 12 files uploaded".
Keep aria-valuenow / min / max as the counts themselves (3, 0, 12), not a percentage.
Rules That Matter
- A progress bar reports; a Slider accepts input. And it isn't a skeleton either — a shimmer stands in for content that's coming, a progress bar says how far along a task is.
- The track is light grey (
--muted); the fill is--primary. The fill is what conveys the value, and it's what has to be legible. A heavier track competes with the fill, which is the opposite of what a progress bar should read as. - Watch where you put it. A
--mutedtrack reads correctly on--cardor--background, but laid directly on--canvasit's nearly invisible —#eef1f3and#f5f5f5are only 1.04:1 apart. That's a placement mistake, not a token one. - The bar is not a label. Pair it with
.ck-progress-metaor anaria-label.
Accessibility
role="progressbar"witharia-valuenow/aria-valuemin/aria-valuemax. Omitaria-valuenowwhen indeterminate.- A task finishing is a visual-only change unless it's announced.
Notes
Track heights are 4 / 8 / 12px, and the indeterminate fill is 30% wide.
A .is-warning fill inherits --warning's 2.05:1, which is under the 3:1 floor for a non-text affordance.
Progress Ring
v3.2progress.mdThe linear progress bar bent into a circle, for where a bar has no room. Same fill tokens, same --track, same tones, same indeterminate state and value transition; four sizes on the icon and control scale.
Sizes
Fill Levels
Tones — the bar’s six
Indeterminate
aria-valuenowWith Its Value — .ck-progress-meta
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--track | #d7dddf | #2b4045 | #2b2b2b | the unfilled arc |
--primary | #013c4b | #e7f9fe | #66d9ef | the fill — running |
--progress-running | #229fbf | #56c2dc | #2fb8d6 | the fill — .is-running, work under way |
--info | #387ff9 | #92c4fe | #44aaff | the fill — .is-info |
--success | #2e9e52 | #42c070 | #44ff88 | the fill — .is-success |
--warning | #f2a618 | #f7b83d | #ffbb33 | the fill — .is-warning |
--destructive | #e50600 | #fe9b98 | #ff4444 | the fill — .is-error |
--foreground | #05262e | #ffffff | #ffffff | the value inside an xl ring |
New Tokens — Geometry
| Size | Class | Size token | Stroke token | Value | Stroke in viewBox units |
|---|---|---|---|---|---|
| Small | .size-sm | --progress-ring-size-sm | --progress-ring-stroke-sm | 16 / 2px | 4.5 |
| Default | .ck-progress-ring | --progress-ring-size | --progress-ring-stroke | 24 / 3px | 4.5 |
| Large | .size-lg | --progress-ring-size-lg | --progress-ring-stroke-lg | 40 / 4px | 3.6 |
| Extra large | .size-xl | --progress-ring-size-xl | --progress-ring-stroke-xl | 64 / 6px | 3.375 |
Rules
- It is the progress bar, bent round. Same fill tokens, same
--track, same five tones, same indeterminate state, same value transition. Use it where a bar has no room: a file row, a table cell, a toast, a card corner. - The value is
--ck-ring-val, 0–100 — the same number asaria-valuenow.pathLength="100"makes the dash the percentage. - Round caps, like the bar’s fill. That is why it is SVG and not a conic gradient. At 0 nothing is drawn: a round cap would otherwise leave a dot.
- A ring is not a label. Put the value beside it in
.ck-progress-meta; only.size-xlcarries it inside. - Put it on
--cardor--background, as the bar:--trackis only its container.
Accessibility
role="progressbar"witharia-valuenow/-valuemin/-valuemaxand anaria-label; omitaria-valuenowwhen indeterminate.- The SVG is
aria-hidden; the host carries the role. - Under reduced motion the spin slows to 3.6s a turn and the fill stops animating.
Badge / Chip / Pill
kitpill.mdOne component, three names — .ck-pill, .ck-badge and .ck-chip are aliases. Always --radius-full; there is no rounded-rectangle badge. Text is always --*-soft-foreground, never the solid token.
The One-Colour Formula
Every badge is one colour at three strengths: 100% for the label and the glyph, 20% for the stroke, 8% for the fill. That is what keeps differently-toned badges reading as one family instead of five unrelated chips. The three swatches under each badge are its own tokens, so they cannot drift from what the badge is actually painted with. Neutral is a tone like any other and is built the same way — it used to borrow--muted and --border, which are the same hex in this theme, so it drew its stroke in its own fill and shipped with no border at all.Two Glyphs, Fixed Box — .is-duo
hugs its label up to 200px, with both glyph lanes fixed. It is a grid, not a flex row, and that is the point: under flex the label is sized by its own content, so a long one squeezes the glyphs and a short one lets them drift inward, and the badge stops reading as one object down a column. Three tracks fix both lanes at --ck-pill-ico and give the label exactly what is left — 150 − 16 padding − 8 gaps − 32 glyphs. Past that it ellipsises and the glyphs never move. The glyphs are the markup’s: any Lucide pair, both inheriting the tone through currentColor. A trailing button is a different thing — that is a dismiss, and it is .ck-icon-btn.size-xs in a plain pill.
Icon-Only Badge — .ck-pill.is-icon
Square at --radius-md, not a lozenge — a single glyph in a round box reads as an avatar or a button, and this is neither. It keeps the pill’s frame, tone map and sizes and drops only the text. It is not a .ck-status: that is a dot beside a label, and removing the label leaves nothing but the dot. A glyph alone is not an accessible name, so aria-label is required.
Solid brand counter — .ck-counter.is-primary
The one counter variant that is not a soft tint. An unread count pinned to a bell has to read as “there is something here” from across the header, which --muted at 1.04:1 against a white page does not do. Every other variant stays soft — a chip in a table column is being read, not noticed.
Fulfilment Status — .ck-fulfil
a quantity, and under it what is still true about that quantity: a delta counter plus a one-word state. The label is plain tone-coloured text, not a pill. Wrapping the word in a filled pill and putting the count inside it made two different facts look like one object, and a column of them read as a wall of tinted rectangles rather than numbers you can scan. The number is the data, the counter is the delta, the word says which.--muted-foreground, never an empty cell: blank reads as “nothing here”, the dash reads as “not known yet”.tones map to the semantic families: remaining is destructive (work outstanding), excess is --violet — over-delivery is neither good nor an error and must not be confused with either — and fulfilled is success.A line, as the product shows it
two quantities with the delta chooser between them. The dashed stroke says “editable, not yet committed”, which a solid one does not.Standalone counter — built like a badge
three tokens, one per job: a-bg fill, a -soft-foreground numeral and a -border stroke. It was a transparent circle with a currentColor ring, which made one colour do all three and left the surface inherited from whatever sat behind it.Counts — abbreviated past four figures
k is the SI prefix for kilo, and this is a count, not a unit. The full number stays as the accessible name, so a screen reader reads “twelve thousand”, not “twelve kay”.Badge / Chip / Pill
.ck-pill · .ck-badge · .ck-chip
What It Is
One component, three names. .ck-badge and .ck-chip are aliases of .ck-pill — use whichever reads better in context. There is no behavioural or visual difference and there must not be one.
Basic Information
Sizes and Height
Height is fixed per size so a row of pills never staggers. The container sizes to the pill, not the reverse.
| Class | Height | Padding | Type | Icon | Counter | Dot |
|---|---|---|---|---|---|---|
.size-sm | 20px | 0 8px | 11px | 10px | 9px | 5px |
| (none) | 24px | 0 10px | 12px | 12px | 10px | 6px |
.size-lg | 28px | 0 12px | 13px | 14px | 11px | 7px |
Icon size scales with the pill, because a 12px glyph in a 20px pill is cramped. Use one size per row of pills.
Width — hugs its text, capped at 200px
One cap, 200px (--badge-max-width), the same at every size and in every context. The pill is exactly as wide as its label up to that point; past it the label truncates head…tail (abc…apso) and the pill stops growing.
It is a cap, never a fixed width — not in a table cell either, where it used to be a fixed 150px. A badge sized to its text says exactly what it holds; a fixed footprint left a column of half-empty capsules.
Counter — hollow, and only hollow
<span class="ck-pill is-success">
<span class="ck-pill-label">Approved</span>
<span class="ck-pill-count">999</span>
</span>Transparent fill, with 1px border and text both currentColor — which resolves to the tone's --*-soft-foreground. --radius-full so it matches the pill it sits in, and font-variant-numeric: tabular-nums so a column of counters aligns.
The pages shipped three forms — solid, hollow and neutral-grey. Only the hollow form survives.
A negative margin-inline-end pulls the counter into the pill's padding, so adding one doesn't make the pill taller or push its right edge out.
Icons — either side, or both
<span class="ck-pill is-warning"><svg/><span class="ck-pill-label">Left</span></span>
<span class="ck-pill is-info"><span class="ck-pill-label">Right</span><svg/></span>
<span class="ck-pill is-error"><svg/><span class="ck-pill-label">Both</span><svg/></span>Icons take currentColor, so they follow the tone with no extra rule. .ck-pill-dot is the small status dot.
Standalone Counter and Status
Same protocol, so a counter in a nav item and a counter inside a badge are the same shape, height and treatment.
<span class="ck-counter is-error">3</span>
<span class="ck-status is-success"><span class="ck-status-dot"></span>Matched</span>| Component | Sizes | Notes |
|---|---|---|
.ck-counter | 16 / 20 / 24px | built like a badge — a -bg fill, a -soft-foreground numeral and a -border stroke, --radius-full, tabular numerals. .on-primary is the one transparent form, for sitting on a filled surface |
.ck-status | 11 / 12 / 13px | dot plus label. The dot is 6 / 8 / 10px in the tone's -soft-foreground |
Fulfilment Status — a pill variant, not a component
| State | Pill |
|---|---|
| fulfilled | .ck-pill.is-success |
| remaining | .ck-pill.is-info |
| excess | .ck-pill.is-warning |
| not fulfilled | .ck-pill.is-error |
<span class="ck-pill is-success">
<span class="ck-pill-label">Fulfilled</span>
<span class="ck-pill-count">42</span>
</span>A count beside a label in a toned pill is the pill's own anatomy — .ck-pill-label plus .ck-pill-count, both of which already ship. A second set of classes for it (.ck-fulfil*) was duplication, so it's retired and there's no CSS for it.
The label is never dropped in favour of the count — status lives in the text and the tone is redundancy, not the message.
Tones map to the semantic families, not to --dfp-*. Those four are the Document Fields Panel's confidence scale, and sharing them would tie two unrelated components to one value.
Tokens
Tones — 22 families
Every tone is the soft quartet: -bg surface, -border border, -soft-foreground text.
| Group | Classes |
|---|---|
| Semantic | .is-success / .ok, .is-warning / .warn, .is-error / .err, .is-info / .info, .is-neutral / .off |
| Palette | .teal .cyan .indigo .pink .rose .amber .lime .emerald .violet .fuchsia .sky .slate .gold .crimson .sage .purple .orange .blue |
Why -soft-foreground exists: 12 of 66 family/theme pairs failed 4.5:1 using the solid token as text — --warning at 1.83, --success at 3.10, --info at 3.26, dark --violet at 3.68, light --orange at 3.82, and more. A soft foreground was minted for all 22 families across all three themes, and every pair now clears 4.5:1.
States
| State | Treatment |
|---|---|
| default | tone surface, tone border, -soft-foreground text |
hover (.is-interactive) | --hover-bg |
active (.is-interactive) | --selected-bg |
focus (.is-interactive) | --focus-ring |
selected (aria-selected="true") | --selected-bg + --selected-fg, --primary border |
disabled (.is-disabled) | --muted surface, --muted-foreground text, --input border |
The Dismiss Cross — the exception to the tone system
<span class="ck-chip is-neutral">
<span class="ck-pill-label">Vendor: Orchard</span>
<button class="ck-icon-btn size-xs" aria-label="Remove Orchard">…</button>
</span>| State | Icon | Fill |
|---|---|---|
| rest | inherit — the pill's own --*-soft-foreground | transparent |
| hover | --destructive-soft-foreground | --destructive-bg |
| active | --destructive-foreground | --destructive (solid) |
| focus | — | --focus-ring-error |
| disabled pill | --muted-foreground | transparent |
It rests in the pill's foreground, not in --destructive. A cross that's red at rest reads as a standing warning on every chip in a filter bar. The destructive tokens appear only on hover, when the action is actually imminent.
This is why .tone-danger is wrong here — that tone is destructive at rest. The pill's dismiss inverts it: neutral at rest, destructive on approach. It's the one place in the kit where a destructive control doesn't announce itself by colour until hover, so it needs its own rules rather than a tone.
Confirmed 2026-09-09. Put to the design lead after the reconciliation flow shipped the cross red at rest for a week: the rule stands unchanged, and the flow was brought back into line. The counter-argument was that a red cross reads as "this removes something"; the answer is that it only reads that way when there's one of them, and a filter bar is the one place where there are twelve. "Clear all" is where the standing red belongs — and .ck-btn.ghost.destructive carries the other half of the same ruling: its label is destructive at rest, and both controls share one hover fill, --destructive-bg.
Active uses the solid pair, not --destructive-border. In high contrast --destructive-border and --destructive-soft-foreground are the same colour, so the icon vanished on its own fill at 1.0:1.
Visual size vs hit area. The dismiss button matches the counter's size so it fits the pill and is round like it — 12 / 16 / 20px by pill size. The glyph inside it is that size less 4px, so there are exactly 2px of clear space on every side: 8-in-12, 12-in-16, 16-in-20.
That derivation is the one place the kit's flat "16px glyph everywhere" does not apply, and it is deliberate. A 16px cross in a 16px button touches the button's bounds, reads a size too large for a 24px chip, and breaks § 14.3 check 11. The 16px rule is about an icon laid out in its own box; a glyph living inside a control takes that control's padding, and a 16px-tall control has none to give.
The hit area is unaffected at 24×24, carried by a ::before that overflows the paint box — WCAG 2.5.8 sizes the target, not the paint. Measured: button 16px, glyph 12px, target 24×24.
Special Rules
Three Specificity Traps
All three are live, so don't "simplify" these selectors:
.ck-icon-btn.size-xsis declared later at equal specificity, so it won and the cross computed to 24px inside a 22px pill — taller than the pill, with a 6px radius inside a round one. The pill's rules use a doubled class (0-3-0) to win.- Lifting to 0-3-0 then meant
color: inheritbeat.ck-icon-btn:hover(0-2-0), so hover rendered identically to rest and the destructive colour never appeared. Hover and active are now declared at pill specificity too. --ck-count-hwas defined on.ck-pillbut not on the.ck-badge/.ck-chipaliases, so the cross and every counter inside a badge or chip collapsed to 0px. There's now one variable block per size covering all three names — an alias must never be able to miss a variable.
Rules That Matter
- Every pill is
--radius-full. There is no rounded-rectangle variant and there must not be one. A 6px radius on a pill is a defect, not a variant. - The text carries the status, never the colour. A dot or icon adds redundancy; neither replaces the label.
- Use
-soft-foregroundfor the text, never the solid tone token. The solid token as pill text is the 1.83:1 case. - A pill is a label, not a control. Add
.is-interactivewhen it becomes a filter chip or clickable status, and it then owes the full six states. Non-interactive pills stay out of the tab order. - A counter is constructed like a badge, and that is the only form.
- The dismiss cross is neutral at rest, which makes it the one exception to the tone system. See below.
Truncation
<span class="ck-pill is-warning">
<span class="ck-pill-label ck-mid-trunc">Missing Document Attachment</span>
</span>.ck-mid-trunc keeps the head and the tail, because the tail of a status label usually carries the distinguishing word. Use it for anything where the tail matters, which is most statuses.
| Full Value | Rendered | On Hover |
|---|---|---|
| Missing Document Attachment | Missing Doc…tachment | full string |
| Over-billed Quantity Variance Detected | Over-bill…Detected | full string |
How much head survives depends on the string and the size class — the algorithm keeps as much as fits rather than cutting to a fixed character count.
Without .ck-mid-trunc, .ck-pill-label falls back to end-ellipsis.
Accessibility
- A truncated label keeps its full value in
title=. For anything essential, addaria-labelwith the full string —titleisn't reliably announced. .ck-counterneeds context: "3 errors", not a bare "3". Usearia-label.- Interactive pills need
aria-selectedoraria-pressed— the fill is visual. - Dismiss buttons need an
aria-labelnaming what's removed.
Notes
Five separate implementations existed before the merge — four page-local (.mock-badge at 96 usages, .badge-counter at 51 with three counter forms, .filter-chip and .td-counter both at border-radius: 6px) and the kit's own, used once.
.is-disabled measures 3.16:1 — the known --muted-foreground on --muted defect, inherited from the token set rather than local to this component.
Kbd
v3.2kbd.md--secondary / --secondary-foreground, because a keycap carries 11px text and muted-foreground on muted is 3.16:1 (§ 9 defect 1).
Kbd
.ck-kbd
What It Is
A keycap shown in running text — a shortcut next to a menu item, a hint in an empty state.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-kbd | one key — use a <kbd> element |
.ck-kbd-combo | a combination, with a translatable joiner |
Sizes
.size-sm 16px · default 20px · .size-lg 24px.
The default matches --icon-box, so a keycap and an icon line up in the same row.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--secondary | #f5f5f5 | #1b292d | #1a1a1a | surface |
--secondary-foreground | #292f32 | #ffffff | #ffffff | surface |
--input | #8b9292 | #5f7073 | #999999 | stroke |
--radius-sm | calc(var(--radius) - 4px) | — | var(--radius-sm) | radius |
States
None — a keycap isn't interactive. If the shortcut itself is actionable, put the keycap inside a .ck-menu-item.
Special Rules
Rules That Matter
- A keycap is an object, not a code span. Inline code is
.ck-tip codeon--radius-xs; a keycap gets--radius-sm, because 2px on a 20px cap just looks like a rendering glitch. - Use
--secondary/--secondary-foreground, not--muted. A keycap carries 11px text, and the muted pair only reaches 3.16:1 — under AA at that size. The secondary pair clears it. - The
+in a combination is real text, so it can be translated and read aloud.
Accessibility
<kbd>carries the meaning. Don't use it for anything that isn't a key.
Don't
- Don't use it as a generic label or badge — that's Badge / Chip / Pill.
Notes
The 2px bottom border is the key's bevel, and the 20px cap is tied to --icon-box.
Divider
v3.2divider.mdFour forms: solid, dashed, labelled and vertical. One colour for all of them — --panel-border, the § 5 visible structural stroke. It was --border, which is 1.09:1 against the page: § 5 scopes that to hairlines inside a surface, not to a rule separating regions, and a divider you cannot see is not doing its job. .is-strong existed only to reach this value, so it is retired along with .is-accent and .is-thick — one divider, one colour, one weight.
dashed
labelled
middle
right
Divider
.ck-divider
What It Is
A rule between regions. Four forms: solid, dashed, labelled, and vertical.
Basic Information
The Four Forms
| Form | Class |
|---|---|
| solid | .ck-divider |
| dashed | .ck-divider.is-dashed |
| labelled | .ck-divider-label |
| vertical | .ck-divider.is-vertical |
The labelled form is the "or" in an Auth Card — a centred label with a rule either side.
Sizes
None. There is one weight.
Tokens
States
None. A divider isn't interactive.
Special Rules
Rules That Matter
- One colour, one weight, for every form:
--panel-border. A rule that varies by emphasis invites a second decision at every call site for no gain. - A divider is its own element, not a border on the thing above it. That's what lets it be dashed, vertical, or labelled without touching its neighbours.
- Use
<hr>. It's alreadyrole="separator". A styled<div>announces nothing. - Don't use
--borderhere. It's scoped to hairlines inside a surface — table rows, list separators, menu dividers — and at 1.09:1 against the page it's invisible. A divider you can't see isn't doing its job.
Accessibility
aria-orientation="vertical"on.is-vertical.- A decorative divider inside an already-labelled group can take
aria-hidden="true".
Notes
.is-vertical is 1px wide and stretches to its row.
.ck-menu-sep is separate and unchanged: it's the menu's own scoped divider, a hairline inside a surface, and correctly reads --border.
There's a contrast ceiling here. --panel-border measures 1.16:1 against the page — more visible than --border, but still low. Making a divider genuinely high-contrast means changing that token's value, which moves every panel stroke in the product with it. That's a token-set decision, not a component one.
Tooltip
kittooltip.mdText-only help on hover and focus. If it holds a control it is a popover. Two variants — default on --primary, .is-light on --popover — across four placements, so eight tooltips. Placement names follow Radix / shadcn, where the side is where the tooltip sits: .is-top means it is above its anchor, so the tail is on its bottom edge. One size: a tooltip is as wide as its content up to 320px, and anything that needs to be bigger is a popover.
Multiline — a title line plus body reads better than one long run
Tooltip
.ck-tip
What It Is
Text-only help, on hover and focus.
Basic Information
A tooltip never carries a shadow
This is a universal rule, not a preference. A tooltip is a transient label, not a surface. Give it a shadow and two things go wrong:
- it reads as a second panel floating above the thing it describes, when it should read as an annotation of it;
- stacked over a bar or a card that already casts a shadow, the two collide and the result looks like a rendering fault.
Its own fill and border carry it: the default variant is a solid --primary bubble, and .is-light has a --panel-border edge. Neither needs help.
Attaching One
<span class="ck-tip-host">
<button class="ck-icon-btn" aria-label="Download Document">…</button>
<span class="ck-tip is-top" role="tooltip">Download document</span>
</span>The host becomes the bubble's containing block, so .ck-tip switches from fixed to absolute and the placement classes set the offset. The tail geometry is unchanged.
Placement offsets are 8px, which clears the tail's 4px protrusion.
The tooltip text and the aria-label say the same thing, and both name the action — "Download document", never "Download icon". Two different strings is a defect: a screen reader user and a sighted user end up with different labels for one control.
Two Variants
<span class="ck-tip open" role="tooltip" id="t1">Rules apply to every document</span>
<span class="ck-tip is-light open" role="tooltip" id="t2">Over dark content</span>| Variant | Surface | Text | Use For |
|---|---|---|---|
| default | --primary | --primary-foreground | the product default |
.is-light | --popover | --popover-foreground | over dark or busy content, where a primary-filled bubble would compete — a dark toolbar, an image, a preview pane |
Neither carries a shadow — see the rule below. Both are --radius-2xl — a tooltip is a floating panel and belongs on the same radius as the rest of them.
Four Placements
Two variants × four placements = eight tooltips.
Placement names follow Radix / shadcn, where the side is where the tooltip sits — so .is-top means the tooltip is above its anchor, and its tail is on the bottom edge pointing down.
| Class | Tail on |
|---|---|
.is-top | bottom edge |
.is-bottom | top edge |
.is-start | inline-end edge |
.is-end | inline-start edge |
The tail is an 8px square rotated 45°, half protruding, inheriting the bubble's fill and stroke — so a .is-light tail carries --panel-border, and a default one is invisible against its own --primary edge.
Two things about the tail are easy to get wrong.
Rotating a square moves its edges. After rotate(45deg) the top border draws the upper-right facet, right draws lower-right, bottom draws lower-left, and left draws upper-left. Each placement keeps the two outward facets and zeroes the two facing the bubble — otherwise the stroke draws a line across the bubble it's attached to.
The tail needs the bubble as its containing block. .ck-tip is position: fixed, which satisfies that. Force position: static and the tail is handed to some ancestor and flies off across the page — use position: relative when showing one inline in a demo.
There is no size scale
One padding (8px 10px), no shadow, max-width: 320px. A tooltip is as wide as its content up to that cap, and as tall as it needs.
Multiline
Supported and expected: white-space: normal, overflow-wrap: anywhere, and line-height: 1.5.
<span class="ck-tip open" role="tooltip">
<span class="ck-tip-title">Linking rules</span>
They decide which documents get matched together, in order.
</span>.ck-tip-title gives a bold lead line. A title plus a body reads better than one long run — it's what makes a three-line tooltip scannable.
.ck-tip code renders an example value in --tip-code-bg, and .ck-tip .ck-tip-eg a dimmed secondary line.
Special Rules
Rules That Matter
- It must appear on focus, not only hover. Hover-only is keyboard-inaccessible, and it's the most common tooltip defect there is.
- Attach it with
.ck-tip-host. Wrap the control, put the.ck-tipinside as a sibling of it, and the kit shows the bubble on hover and on:focus-within— no controller, no per-flow wiring. This is what makes rule 1 achievable rather than aspirational. - It must be dismissible with Escape, and must not disappear when the user hovers over it. Neither is enforceable in CSS, so those two still need the flow's JS.
- Never put the only copy of essential information in a tooltip.
titleis not a tooltip. It doesn't appear on keyboard focus, can't be styled, has an uncontrollable delay, and is announced unreliably. Use it as a redundant fallback, never as the mechanism.- Anything that needs to be bigger is a . A "large tooltip" is a category error — past a few lines the user needs to select the text, click something in it, or keep it open, and all three are popover behaviours.
If it holds a control it's a ; if it's a list of actions it's a Menu.
Truncation
The truncated element and its tooltip are the same component, so they can't diverge in style. That works in both directions:
- A truncated element's tooltip carries its full value. Anything with
.ck-mid-trunc— a pill label, a filename, a table cell — shows its full string in a.ck-tip.ckMiddleTruncateputs it intitlefor free, buttitleisn't enough on its own, so wire a real tooltip. - A tooltip's own text truncates the same way. If the tooltip content is itself too long,
.ck-mid-truncapplies inside it — and.ck-tip.is-single-lineforces one line with an end ellipsis where that reads better.
Accessibility
role="tooltip", and the trigger needsaria-describedbypointing at it.- Every interactive icon gets a real tooltip.
Spinner & Skeleton
kitfeedback.mdThe spinner's track is --input, not --border: at 1.09:1 the track was invisible, so it read as a lone rotating arc rather than a ring. Skeleton primitives exist so a loading state can be built to the shape of the thing it replaces rather than as a grey rectangle.
Feedback and Utilities
What It Is
Spinner · Skeleton · Middle truncation
Three small pieces that share one theme: they communicate state or shape rather than content, so their contrast floor is 3:1, not 4.5:1.
The tooltip used to live here. It has its own sheet now: Tooltip.
Basic Information
Spinner — .ck-spinner
<span class="ck-spinner size-sm" role="status" aria-label="Loading"></span>An indeterminate ring: a full border in --skeleton-sheen with the top edge in --primary, rotating.
It is display: inline-block. Without that it is inline, width and height are ignored, and a standalone spinner renders about 4px wide — it only looked right inside a flex row.
Sizes
Its own scale — a spinner sits inside other things.
| Class | Box | Border | Reach for it when |
|---|---|---|---|
.size-sm | 16px | 2px | inside a control — a search box, a button, a dropdown's search row |
| (none) | 24px | 2px | default — inline beside a label, in a toolbar. It reads --icon-box, so a spinner swapped in for an icon never resizes the row |
.size-lg | 28px | 3px | a panel or region loading as a whole |
.sm / .lg survive as aliases; new markup uses .size-*.
Never centre a spinner with transform
ck-spin animates transform, so a transform: translateY(-50%) used for centring is replaced by the rotation the moment the animation starts. With a single-keyframe animation the browser then interpolates from the translate to the rotation, so the spinner slides downward through every revolution and snaps back — a drift that looks like a rendering fault.
Centre it with inset-block: 0; margin-block: auto instead, which leaves transform free. This bit the search box, the dropdown's search row and the menu's search row, all three of which position the spinner absolutely.
Radius
--radius-full.
Skeleton — .ck-skeleton
<div class="ck-skeleton-row">
<div class="ck-skeleton ck-skeleton-circle" style="width:32px;height:32px"></div>
<div class="ck-skeleton ck-skeleton-text" style="flex:1"></div>
</div>| Class | Role |
|---|---|
.ck-skeleton | the shimmer: --muted → --skeleton-sheen → --muted |
.ck-skeleton-text | 12px tall |
.ck-skeleton-title | 16px tall, capped at 40% width |
.ck-skeleton-circle | --radius-full, for an avatar |
.ck-skeleton-row | flex row with a 10px gap |
Radius
--radius-sm, except .ck-skeleton-circle which is --radius-full.
Middle Truncation — .ck-mid-trunc
<span class="ck-mid-trunc">PO-2024-00266 · Orchard Provisions.pdf</span>A utility, not a component. Paired with ckMiddleTruncate in clipper-kit.js?v=3ce65335, which keeps the head and tail and drops the middle — so PO-2024-00266 · Orchard Provisions.pdf becomes PO-2024-00266 · Orch…s.pdf rather than losing its extension.
Tokens
The loading family shares one tone
Spinner track, skeleton sweep and the reduced-motion flat fill all run on --skeleton-sheen — one step off --muted, per theme: #e8eaeb / #26383d / #2b2b2b.
That token was minted for exactly this. Both used to run through --input, a control-border grey at 3.17:1 — correct for a border, far too heavy for a placeholder, which should read as absent content rather than as a filled bar. It also made the spinner's ring compete with the arc that carries the motion.
The affordance still carries the contrast: the spinner's arc is --primary at 12.01:1, as is the progress fill. The track is context in both cases — deliberately quiet, not invisible.
Special Rules
Rules That Matter
- A spinner is not a progress bar. If the duration is known, show progress.
role="status"with anaria-label, or an adjacent visible label. A bare rotating div announces nothing.- Under
prefers-reduced-motionthe animation stops; the element stays. Hiding it would leave no indication anything is happening.
Rules That Matter
- A skeleton means "content is coming"; an empty state means "there is none." The filled shimmer versus the dashed outline is the whole distinction — never use a skeleton for an empty result.
- No sizes. A skeleton is sized to the thing it stands in for, inline or by its container.
- Under
prefers-reduced-motionthe gradient flattens to--mutedand the animation stops.
Rules That Matter
- **Use it where the tail carries meaning** — filenames, identifiers. For prose, ordinary
text-overflow: ellipsisis right. - Screen readers read the visible text, so the truncated form is what's announced.
titleholds the full value for hover, but where the full value matters, add anaria-labelwith the complete string.
It binary-searches the head length against a canvas measurement of the element's own computed font, so it's exact at any width, and re-runs on resize.
Field controls — input, label, textarea, select, search
v3.2input.mdOne family, one stroke. .ck-input, .ck-select, .ck-textarea, the input inside .ck-search and .ck-dd-trigger all carry the same 1px --input border and the same five stroke states, so a column of mixed controls has one edge. Heights are the control scale — 28 / 36 / 44 — set explicitly with inline padding only, because a control sized by padding alone lands on its own font metrics. Trailing order is fixed: text, then the clear cross, then the search glyph or chevron.
Input — all six states
Combobox — .ck-combo, a field you can type a new value into
A field you type into that filters a list, and which can accept a value that is not in the list at all. That last part is why it is not a dropdown: .ck-dd chooses among known options; this is for a value that might already exist or might be one you are inventing as you type it. The control joins .ck-input's own selector, so the field base is inherited rather than restated.
Dropdown with a left icon — .ck-dd.has-icon
A glyph that identifies the value before the value is read. This replaced a whole .ck-phone molecule: a country picker is a dropdown with a leading glyph, and so is a currency picker and an account picker — one variant, not one component each. .is-compact keeps it content-width beside the field it belongs to.
Sizes — 28 / 36 / 44, and they must match across a row
Label, hint, error, required, optional
required, or the asterisk is decoration only.Trailing icons — position and combination
The clear reveals on hover — never resident
hover or Tab into each field. A row of filled fields should not be a row of crosses, so the clear is hidden until the field is hovered or holds focus — and it is the same 16px glyph in a 24px target everywhere.Textarea — sizes and the resize handle
.ck-field-grid column — dragging one field wider would break the grid's alignment and every field beside it. Dragging never goes below the size class’s min-height, so a textarea cannot be collapsed to nothing. Use .no-resize where the row height is fixed, such as an editable table cell.Input / Field
.ck-input · .ck-select · .ck-textarea · .ck-field
What It Is
Two layers: .ck-field is the labelled wrapper (label, control, hint, error), and .ck-input / .ck-select / .ck-textarea are the bare controls. .ck-field-grid lays fields out in columns.
Basic Information
Anatomy
<div class="ck-field-grid cols-2">
<div class="ck-field">
<label class="ck-field-label" for="po">Purchase order
<svg class="ck-info">…</svg>
</label>
<input id="po" class="ck-input" placeholder="PO-2024-00266">
<span class="ck-field-hint">Matches on exact reference</span>
</div>
</div>| Class | Role |
|---|---|
.ck-field-grid | 3 columns, 16px gap. .cols-2 / .cols-1 to narrow |
.ck-field | one field — column flex, 6px gap |
.ck-field-label | 500 / 13px in --foreground; holds an optional .ck-info |
.ck-field-hint | 400 / 12px in --muted-foreground |
.ck-field-error | 400 / 12px in --destructive |
.ck-input-wrap | positioning context for .ck-input-clear |
Sizes
| Class | Height | Padding | Type | Reach for it when |
|---|---|---|---|---|
.size-sm | 28px | 0 9px | 12px | inline filter bars, a search box in a panel header, editable table cells, anything repeated per row |
| (none) | 36px | 0 11px | 13px | default — every .ck-field-grid, every dialog form, settings panels |
.size-lg | 44px | 0 13px | 13px | auth and onboarding, a single prominent search, touch-first layouts |
Textarea has no fixed height, so its sizes move padding and min-height:
| Class | Min-height | Padding |
|---|---|---|
.size-sm | 60px | 6px 9px |
| (none) | 76px | 8px 11px |
.size-lg | 96px | 10px 13px |
Use .cols-2 / .cols-1 in narrow panels — three columns in a 320px drawer doesn't fit.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--input | #8b9292 | #5f7073 | #999999 | border · border-input |
--background | #ffffff | #142226 | #000000 | fill · bg-background |
--foreground | #05262e | #ffffff | #ffffff | text · text-foreground |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | placeholder · placeholder:text-muted-foreground |
--input-border-hover | #4d6b72 | #9caeb2 | #82b6c0 | hover border · — |
--primary | #013c4b | #e7f9fe | #66d9ef | focus border · focus-visible:border-ring |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus ring · ring-ring/50 |
--destructive | #e50600 | #fe9b98 | #ff4444 | invalid border · aria-invalid:border-destructive |
--focus-ring-error | 0 0 0 3px #f9c8c7 | 0 0 0 3px #473d3f | 0 0 0 3px #380f0f | invalid focus ring · ring-destructive/20 |
--muted | #f5f5f5 | #1b292d | #1a1a1a | disabled fill · disabled:opacity-50 |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | disabled text · — |
--radius-md | calc(var(--radius) - 2px) | — | var(--radius-md) | radius · rounded-md |
Same divergence from shadcn as the other controls: disabled uses tokens rather than opacity-50, so the text keeps its contrast.
States
| State | Treatment |
|---|---|
| default | --input border on --background |
| hover | --input-border-hover |
| focus | --primary border + --focus-ring |
| invalid | --destructive border; --focus-ring-error on focus |
| disabled | --muted fill, --muted-foreground text, not-allowed |
| read-only | --muted fill, text stays --foreground |
locked (.ck-field.is-locked) | same as disabled, applied from the field wrapper |
Special Rules
Trailing icon order never varies
text → clear cross → search glyph or chevron.
| Field | Trailing Affordance | With a Value |
|---|---|---|
.ck-input | none | an optional .ck-input-clear cross |
.ck-search | the search glyph, 15px at inset-inline-end: 10px | the cross at 30px, inboard of the glyph |
.ck-search.is-loading | a .ck-spinner.size-sm in the glyph's place | cross unchanged |
.ck-select | a filled caret, drawn with two gradients | — |
.ck-dd-trigger | a stroked chevron | — |
Four rules hold this together:
- One trailing affordance, plus an optional cross. Never a chevron and a search glyph on the same field — two trailing glyphs read as two different controls.
- The cross sits inboard of the glyph, never outboard. The glyph says what the field is; the cross acts on its content, so it belongs closer to the text.
- Both offsets are logical (
inset-inline-end), so the order flips correctly in RTL without a second rule. - The spinner replaces the glyph in place. It must not appear on the opposite side — a loading indicator that jumps across the field lands on top of what the user just typed.
The end padding that reserves room is fixed: 36px for a glyph, 56px once a cross is showing. Change a glyph size and the padding has to move with it.
Required and Optional
| Markup | Renders | Why |
|---|---|---|
<label class="ck-field-label" data-required> | a --destructive asterisk after the text | it is punctuation, not content, so it is a ::after and never reaches the accessibility tree as a word |
<span class="ck-field-optional">(optional)</span> | muted 12px inside the label | it is a word, so it must be a real element and translatable. A ::after with content:'(optional)' would hardcode English in CSS |
- The asterisk is decoration only. The field must also carry
required(oraria-required="true"), or the requirement exists for sighted users alone. - Never both on one label. A field is one or the other.
- Prefer marking the optional ones when most fields in a form are required — fewer marks, same information.
The Textarea Resize Handle
Vertical only. resize: vertical is deliberate:
- Horizontal is off because a field's width belongs to its
.ck-field-gridcolumn. A horizontal handle would let one field break the grid's alignment and every field beside it. - Dragging cannot go below the size class's
min-height(60 / 76 / 96px), so a textarea can never be collapsed to nothing. - Dragging up is unbounded, which is correct — the user is asking to see more of their own text, and no maximum we pick would be right for every container.
.no-resizesuppresses the handle where the row height is fixed, such as an editable table cell.
The handle is the browser's own, so it inherits the platform's affordance rather than a drawn one. Don't reimplement it.
Rules That Matter
- Every control needs a programmatic label —
for/id, oraria-label. A placeholder is not a label; it disappears on the first keystroke. - Height is explicit; padding is inline only. Sized by padding alone,
.ck-inputcomputed to 35px and.ck-selectto 37px against a 34px button, because each landed on its own font metrics. An explicit height makes a row align across fonts and zoom levels. - Size every control in a row together.
.ck-ddtakes the same.size-*classes precisely so a dropdown and a text field can share a grid. aria-invalid="true"and.ck-field-errorgo together. A red border with no message says nothing; a message with noaria-invalidisn't announced. Pointaria-describedbyat the error so the reason is read, not just the state.readonlyanddisabledare different. Read-only text stays full-contrast because it's still meaningful content; disabled text goes muted because the field is out of play. Don't disable a field the user may still need to read.- Never put required information in a placeholder.
- Long text: read-at-a-glance truncates, typed text scrolls. A label and a dropdown value end in an ellipsis, because they are scanned. An input and a search box scroll sideways and a text area scrolls down, because hiding what the user just typed behind an ellipsis is worse than a scroll.
- Width follows the parent.
width: 100%withmax-width: 100%andmin-width: 0, so the field takes the column it is given and can shrink inside a flex or grid track instead of forcing that track wider. Never a fixed pixel width. - Everything on one line shares one height, and it is an even number. A field, a button and an icon button beside each other all come off the 28 / 36 / 44 scale. Even heights mean the row's centre line lands on a whole pixel, so glyphs and text do not sit a half-pixel apart.
- The clear cross appears on hover, never at rest. A form of filled fields should not be a row of crosses. It is revealed by the wrapper —
has-valueplus:hoveror:focus-within— because a control atopacity: 0cannot be hovered into view by itself, and a keyboard user has to be able to reach it. Same standard 16px glyph in a 24px target on all four variants.
One Base, Four Variants
.ck-input, .ck-select, .ck-textarea and .ck-dd-trigger are one rule:
.ck-input,.ck-select,.ck-textarea,.ck-dd-trigger{ … }Height, padding, type, colours, stroke, radius and transition are declared once, there. Each variant then overrides only what it genuinely differs in:
| Variant | What it adds |
|---|---|
.ck-input | nothing — it is the base |
.ck-select | appearance: none and the caret |
.ck-textarea | height: auto, a min-height, block padding, resize |
.ck-dd-trigger | display: flex, align-items, gap, cursor, text-align — five properties, because it lays a value and a chevron out as a row and is clickable |
Hover and aria-invalid are shared on the same selector list, so a state added to the family reaches all four.
.ck-search is not a variant — it is a wrapper. It requires a .ck-input inside it and adds exactly one declaration, padding-inline-end. That is deliberate: being a wrapper is what lets it inherit all three sizes and every input state for free.
Why this matters. .ck-dd-trigger used to re-declare 9 of the base's 10 properties. That is the same duplication that once bit .ck-search, which had its own copy of the input's rules — so when aria-invalid, :disabled, readonly and ::placeholder were added to .ck-input, the search box silently got none of them, and nothing in either rule showed the problem. The duplication is now zero.
Stroke — one contract, four controls
1px, --input, all four sides, at every state, with no thickness or colour deviation and no uneven sides — now by construction rather than by hand.
Focus keys on :focus here, not :focus-visible, and that's deliberate: clicking into a text field places a caret and should show focus. .ck-dd-trigger uses :focus-visible because clicking it opens a panel and picks up the same ring via .ck-dd.open — so the two look identical in practice.
When measuring, force both :focus and :focus-visible. Forcing only the latter reports a false mismatch.
The Select Alignment Defect
.ck-select shipped with appearance: auto. Chrome then paints the option text inside its own inner box on top of the kit's padding, so a select stacked under an input in the same grid had its text visibly further right — with identical CSS and identical padding boxes. Invisible in a rule diff and obvious on screen.
appearance: none removes the UA's inner box. The caret is redrawn with two linear-gradient layers rather than a data-URI SVG, so its colour stays var(--muted-foreground) and follows the theme.
**One ordering trap: the caret must be declared after the shared .ck-input,.ck-select,.ck-textarea rule.** That rule uses the background shorthand, which resets background-image — declare the caret before it and it never paints.
The caret is a filled triangle where .ck-dd-trigger uses a stroked chevron. A <select> is a replaced element and can't take a pseudo-element, so the two differ. Use .ck-dd where that matters.
Accessibility
.ck-field-hintshould also be wired witharia-describedby..ck-infoin a label needsaria-labelor adjacent text — a bare help glyph announces nothing.- Placeholder contrast is 3.44:1 in light — under the 4.5:1 body-text floor, though it clears 3:1.
--muted-foregroundwas lightened to#738f96deliberately, and the placeholder is the most exposed thing in the kit, so never put required information in one (rule 6) matters more than ever. - Use
.ck-field-hintfor standing guidance. A hint that only appears on error should be.ck-field-error.
Notes
The input border is why --input was corrected — an input's border is its affordance, so the 3:1 non-text floor binds on it.
Disabled text at 3.16:1 is the known --muted-foreground on --muted defect, inherited from the token set. Don't patch it here.
AI Field — .is-ai
a field whose content came from, or is going to, the model — the AI feedback box. It reads--purple-soft-foreground on its stroke so it is legible at a glance as “this one is the AI’s”, without changing its shape, height or type. It is the same control, marked — not a different control.Search Box
kitsearch.mdA wrapper around a .ck-input, not a parallel control — it adds only the trailing glyph, the clear button and the loading swap. It has no sizes of its own: size the input inside it.
Search Box
.ck-search
What It Is
An Field controls — input, label, textarea, select, search with a trailing search glyph, an optional clear button, and a loading state.
Basic Information
Anatomy
<div class="ck-search has-value">
<svg viewBox="0 0 24 24" …>…</svg> <!-- trailing glyph -->
<button class="ck-search-clear" aria-label="Clear Search">…</button>
<input class="ck-input" value="Orchard" aria-label="Search Folders">
</div>| Class | Role |
|---|---|
.ck-search | positioning wrapper. Adds nothing to the input but padding |
.ck-search > svg | trailing glyph, pointer-events: none |
.ck-search > .ck-spinner | replaces the glyph while .is-loading |
.ck-search-clear | clear button, revealed by .has-value |
.is-loading | swaps glyph for spinner |
.has-value | reveals the clear button and widens the input's end padding |
.is-loading and .has-value are set by the flow's JS. The kit doesn't own the input's value.
Sizes
It has none, deliberately — being a wrapper means it inherits all three steps of the control scale (28 / 36 / 44) plus every input state, for free.
<div class="ck-search"><svg…><input class="ck-input size-sm" …></div>| Use | Why |
|---|---|
.ck-input.size-sm | the common case — a panel header, toolbar, or filter bar, all dense contexts |
.ck-input | a search box that's a form field like any other |
.ck-input.size-lg | a page's primary search, or a touch-first surface |
The glyph does not scale with the input. It stays 15px at every size, because the end padding reserving room for it is fixed (36px, or 56px with a clear button). If a size ever needs a different glyph size, the padding has to move with it.
Geometry
| Property | Value |
|---|---|
| glyph | 15px, inset-inline-end: 10px |
| clear button | 24px, inset-inline-end: 30px |
| input end padding | 36px; 56px with .has-value |
Trailing order is text → clear cross → search glyph, cross inboard of the glyph — so in edit mode the cross appears immediately to the left of the search icon, never outboard of it and never on the opposite side. When .is-loading replaces the glyph with a spinner, the cross does not move. Both offsets use inset-inline-end, not right, so the order flips correctly in RTL without a second rule.
The clear button is 24px — the WCAG target-size floor.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--background | #ffffff | #142226 | #000000 | field fill |
--foreground | #05262e | #ffffff | #ffffff | typed text |
--input | #8b9292 | #5f7073 | #999999 | field stroke |
--input-border-hover | #4d6b72 | #9caeb2 | #82b6c0 | stroke, hover |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | the magnifier, the placeholder, the clear glyph |
--primary | #013c4b | #e7f9fe | #66d9ef | stroke and ring on focus |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus |
--destructive | #e50600 | #fe9b98 | #ff4444 | stroke when invalid |
--muted | #f5f5f5 | #1b292d | #1a1a1a | fill when disabled or read-only |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | the clear button's hover pad |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | the clear button's pressed pad |
--radius-md | calc(var(--radius) - 2px) | — | var(--radius-md) | the field |
--radius-sm | calc(var(--radius) - 4px) | — | var(--radius-sm) | the clear button's pad |
--icon-size | 16px | 16px | 16px | every glyph on the field |
Special Rules
Rules That Matter
- It's a wrapper, not a variant.
.ck-input,.ck-select,.ck-textareaand.ck-dd-triggerare four variants of one base rule; the search box is a container around one of them, adding a single declaration. That distinction is the whole point of this component — being a wrapper is what makes it inherit every size and state for free. - The input inside must carry
.ck-input. A bare<input>will be unstyled..ck-search > .ck-inputsets exactly one thing:padding-inline-end. - Size the input, not the wrapper.
.ck-search.size-smdoes nothing. Put the size class on the.ck-input. - The input needs a real accessible name. A placeholder disappears on the first keystroke, so it is not a label.
- Clearing returns focus to the input — otherwise focus is stranded on a button that has just hidden itself.
- Long text scrolls sideways — a search box is a field the user types in, so what they typed is never hidden behind an ellipsis.
- The clear cross appears on hover, never at rest, and sits on the left, next to the magnifier — see Field controls — input, label, textarea, select, search for the shared contract.
Accessibility
- The clear button needs
aria-label="Clear Search"— it's glyph-only. - For async results, announce the result count in a live region. The spinner is visual only.
- Everything else — invalid, disabled, contrast, focus — is Field controls — input, label, textarea, select, search's, which is the point.
Don't
- Don't show the clear button when there's no value.
- Don't leave
.is-loadingon a spinner that never resolves.
Notes
The spinner is centred with inset-block: 0; margin-block: auto, never with transform. ck-spin animates transform, so a translateY(-50%) would be replaced by the rotation — the spinner slid down through every revolution and snapped back. See Spinner & Skeleton § Never centre a spinner with transform.
Why the wrapper matters. .ck-search input used to re-declare the input from scratch, sharing 7 of its 8 declarations with .ck-input. The cost wasn't the bytes — it was that every input improvement had to be made twice, and the ones added in the Input pass (aria-invalid, :disabled, readonly, ::placeholder) landed on .ck-input and not on .ck-search input. So a search box couldn't show an invalid or disabled state at all, and nobody would have noticed from reading either rule alone.
Breadcrumb
v3.2breadcrumb.mdStandalone as of v3.2 — it had lived only inside the header bar, on unprefixed classes. .ck-breadcrumb* are canonical now and the unprefixed names stay as aliases. The current crumb steps up to 18px semibold against the trail's 16px, keyed to aria-current="page" so the drawing cannot drift from what is announced. A crumb is interactive, so it now carries a focus ring — it had none.
Breadcrumb
.ck-breadcrumbs · .ck-breadcrumb-item · .ck-breadcrumb-sep
What It Is
The trail showing where the current page sits in a hierarchy, and a way back up it. Standalone as of v3.2 — it had lived only inside Header Bar, on unprefixed classes.
.ck-breadcrumb* are the canonical names. The unprefixed .breadcrumbs / .breadcrumb-item / .breadcrumb-sep stay as aliases because pages already use them, the same arrangement as .switch / .ck-switch.
Basic Information
Anatomy
<nav class="ck-breadcrumbs" aria-label="Breadcrumb">
<a class="ck-breadcrumb-item" href="#">Purchase Orders</a>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<a class="ck-breadcrumb-item" href="#">Pending</a>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<span class="ck-breadcrumb-item" aria-current="page">Folder Settings</span>
</nav>| Class | Role |
|---|---|
.ck-breadcrumbs | the trail — a <nav> with an aria-label |
.ck-breadcrumb-item | one crumb. A link, except the current one |
.ck-breadcrumb-sep | the separator; aria-hidden |
.ck-breadcrumb-more | the collapsed middle — a real <button> |
Sizes
| Class | Trail | Current Crumb | Reach for it when |
|---|---|---|---|
| (none) | 16px medium | 18px semibold | default — the page-level trail in a header bar |
.size-sm | 13px | 14px | a trail inside a dialog, drawer or panel header, where 16/18px would out-shout the panel's own title |
Density is a token decision: override --breadcrumb-size (16px) and --breadcrumb-gap (4px) on the trail.
Collapsing a Deep Trail
A trail deeper than about four levels stops being scannable. Collapse the middle behind one .ck-breadcrumb-more control that reveals the rest:
<a class="ck-breadcrumb-item" href="#">Purchase Orders</a>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<button class="ck-breadcrumb-more" aria-label="Show 3 Hidden Levels">…</button>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<span class="ck-breadcrumb-item" aria-current="page">Folder Settings</span>Keep the first and the current crumb visible — the root and where you are are the two a user actually needs.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | a resting crumb, and the separator |
--foreground | #05262e | #ffffff | #ffffff | the current crumb, and any crumb on hover |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | hover fill on a crumb |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | pressed fill |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | the focus ring |
--breadcrumb-size | 16px | — | — | trail type size, 16px at medium weight — the separator reads it too |
--breadcrumb-gap | 4px | — | — | crumb ↔ separator spacing, 4px |
States
| State | Treatment |
|---|---|
| rest | --muted-foreground |
| hover | --foreground on --hover-bg |
| active | --selected-bg |
| focus-visible | --focus-ring |
| current | --foreground, 18px semibold, not interactive, no hover fill |
Special Rules
The trail is medium; only the current crumb is semibold
Two axes separate them, weight and size — 16px medium for where you came from, 18px semibold for where you are.
The inactive crumbs used to be semibold too, which left 2px of size as the only difference between the two, and at a glance a five-level trail read as five titles rather than one path with a destination.
- Mark the current crumb with
aria-current="page". That is what the styling keys on, so the drawing cannot drift from what a screen reader announces..currentis a transitional alias — don't use it in new markup. - The current crumb is not a link. Make it a
<span>. A link to the page you are already on is a dead control. - The trail must be a
<nav>with anaria-label. Without the label it is an unnamed landmark, and a page usually has several. - Separators are decorative —
aria-hidden="true". They are punctuation, not content, and a screen reader reading "slash" between every level is noise. - A crumb is interactive, so it owes a focus ring. It had none until v3.2: the browser default outline doesn't follow the theme, and a missing focus ring is a Sev-1.
- The separator reads
--breadcrumb-size, so changing the trail's size moves the chevrons with the text instead of leaving them behind. A glyph separator sizes at1emfor the same reason. - Don't hand-roll the trail inside a header bar. Header Bar builds it from
data-crumbs; a per-page copy is how the header drifts between flows. Use this component directly only where there is no header bar.
Accessibility
- Crumb hit areas are
4px 6pxof padding on 16px text, which is under the 24×24 target floor. It is tolerable for an inline text link in a trail, but don't shrink the padding further. - The collapsed control is a real
<button>with anaria-labelnaming how many levels it hides — "Show 3 hidden levels", not "…".
Tabs
kittabs.mdThree variants of one component — underline (the default), pill and segmented. They differ only in CSS: the behaviour, the keyboard pattern and the ARIA are identical, which is the point of them being one component rather than three. All three are live — click them, or focus one and use the arrow keys.
With Counts
Underline — .ck-tabs
for tabs that are sections of a page. The active indicator is 2px of--primary sitting in the 2px --panel-border rail, pulled up by exactly the rail's thickness — no gap between the underline and the separator.Pill — .ck-tabs.is-pill
for peer modes of the same data. A recessed--segment trough with the active tab lifted out of it.Segmented — .ck-tabs.is-segmented
for a small closed set of choices that form a scale. One bordered block split by 1px--input dividers, the active segment filled solid.How each one shows selection
underline — the coloured segment of the rail. Pill — three signals at once, because the white plate alone is 1.13:1 against the trough: the plate lifts, the label goes from--segment-foreground to --foreground, and the weight goes semibold. Segmented — selection can rest on the fill, since --primary-foreground on --primary is 12.01:1.Labels with Icons
leading, trailing or both, at--icon-size and --icon-stroke. A leading glyph classifies the tab; a trailing one points at what selecting it does. A count goes in a badge, never in a glyph.Sizes
Disabled
a disabled tab is skipped by the arrow keys, not focused and ignored.Error — a tab whose panel failed to load
Tabs
.ck-tabs · .ck-tab
What It Is
Switches between sibling views of the same object without navigating away.
Basic Information
Anatomy
<div class="ck-tabs" role="tablist">
<button class="ck-tab" role="tab" aria-selected="true" aria-controls="p1" id="t1">Groups</button>
<button class="ck-tab" role="tab" aria-selected="false" aria-controls="p2" id="t2">Linking rules</button>
<button class="ck-tab" role="tab" aria-selected="false" disabled>Audit log</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1">…</div>Three Variants of One Component
They differ only in CSS. The behaviour, the keyboard pattern and the ARIA are identical, which is the point of them being one component rather than three.
| Variant | Class | Reach for it when |
|---|---|---|
| Underline | (none) | the tabs are sections of a page. The default |
| Pill | .is-pill | the tabs are peer modes of the same data — grid vs list, one dataset four ways |
| Segmented | .is-segmented | a small closed set of mutually exclusive choices that form a scale — Day / Week / Month / Year |
Stroke Widths, Stated Exactly
| Where | Width | Token |
|---|---|---|
| Underline rail | 2px | --panel-border |
| Underline active indicator | 2px | --primary |
| Segmented outer edge | 1px | --input |
| Segmented divider between segments | 1px | --input |
| Pill trough | 0 — it is a fill | --segment |
| Pill active plate | 1px + --shadow-raised | --panel-border |
Anything thicker reads as a structural rule; anything thinner disappears against the page.
No gap between the underline and the separator
The active indicator is pulled up by exactly the rail's own thickness, so it sits in the rail's band rather than below it:
.ck-tabs{--ck-tab-rail:2px; border-bottom:var(--ck-tab-rail) solid var(--panel-border)}
.ck-tab[aria-selected="true"]::after{bottom:calc(-1 * var(--ck-tab-rail)); height:var(--ck-tab-rail)}The two read as one continuous line whose active segment is coloured. Offset it by anything else and you get either a hairline of --panel-border showing through beneath the indicator, or a 1px step where the two colours meet. Measured flush: indicator 2px at bottom: -2px against a 2px rail.
How Each Variant Shows Selection
Underline — the 2px --primary segment of the rail, plus --primary on the label.
Pill — three signals, not one. The plate lifts to --background, the label moves from --segment-foreground to --foreground, and the weight goes semibold. The plate alone is 1.13:1 against the trough, so on colour it would be no signal at all.
Segmented — selection can rest on the fill here, because --primary-foreground on --primary is 12.01:1: that is a genuine surface change, not a tint.
In all three, selecting a tab deselects its siblings — a tablist is single-select by definition, and ckTabs() enforces it rather than trusting the markup.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--panel-border | #ebeff0 | #43575a | #99a7ab | the underline rail, and the pill's active plate |
--primary | #013c4b | #e7f9fe | #66d9ef | the active indicator, and the segmented active fill |
--primary-foreground | #ffffff | #05262e | #000000 | the segmented active label |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | an inactive underline label |
--foreground | #05262e | #ffffff | #ffffff | an active pill label, and a hovered underline label |
--segment | #eef1f3 | #1b292d | #1a1a1a | the pill trough |
--segment-foreground | #3f5359 | #bcd7dd | #e0e0e0 | an inactive pill label |
--input | #8b9292 | #5f7073 | #999999 | the segmented edge and its dividers, and a disabled label |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | hover on any variant |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | pressed |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus on the underline variant |
--card | #ffffff | #233a3e | #000000 | the connected panel |
--shadow-raised | var(--shadow-md) | var(--shadow-md) | var(--shadow-md) | the pill's active plate |
--segment / --segment-foreground were minted for the pill trough. No existing surface fitted: --muted is 1.04:1 against the page so the trough vanished, and --canvas is the app ground, which § 4 forbids putting a control group on. The label clears 7.14:1 on it in light, 9.92 in dark, 13.18 in high contrast.
Sizes
A tab bar is a structural strip, not a control in a row. Its two heights happen to be the top two steps of the control scale (36 and 44), but it is sized as a strip: there is no 28px step, because a tab bar that dense stops reading as a region boundary.
| Class | Bar Height | Tab Padding | Type | Reach for it when |
|---|---|---|---|---|
.size-sm | 36px | 0 12px | 12px | tabs inside something — a dialog, drawer, panel or card. Also secondary tabs under a primary set |
| (none) | 44px | 0 16px | 13px | default — the primary tab bar of a page or full-width view |
There's no .size-lg. Past 44px a tab bar stops reading as a strip and starts competing with the page header.
Radius
None on the bar; the active indicator is a square 2px rule. A tab bar is a strip, not a panel.
Icons
Lucide, 16×16, stroke-width 2, currentColor. The glyph size doesn't vary with the size class; only the box around it does.
Special Rules
Rules That Matter
- Tabs are for views of one object. For moving between different objects, use navigation. If a tab set needs more presence than a strip, it's probably navigation.
- Selection is driven by
aria-selected, not a class..activesurvives as a transitional alias, but new markup uses the attribute so the visual state can't drift from what a screen reader announces. - Size the bar, not the tabs.
.size-smon.ck-tabscascades down. A size class on an individual.ck-tabgives you a ragged bar. - 2–6 tabs. Nine tabs that wrap to a second line means you want a dropdown or navigation instead.
- Every tab needs a panel, wired with
aria-controlsandaria-labelledby. - Pick the variant from what the tabs mean, not from how they look. Sections take the underline, modes take the pill, a scale takes the segmented block. Three variants exist so a screen can say which of those it is; using them interchangeably throws that away.
- Icons are
--icon-sizeat--icon-stroke, either side or both. A leading glyph classifies the tab; a trailing one points at what selecting it does. A count goes in a badge, never in a glyph. - The active indicator never leaves a gap above the separator. Offset it by the rail's own thickness — see above.
- A disabled tab is skipped by the arrow keys, not focused and ignored.
ckTabs()filters them out of the roving-tabindex list.
Accessibility
role="tablist"on the bar,role="tab"on each tab,role="tabpanel"on each panel.- Arrow keys move between tabs and Tab exits the set. That's the ARIA tabs pattern and it needs JS — the CSS doesn't provide it, and the kit has no tab controller yet, so each flow wires its own. Worth extracting.
- Once arrow-key navigation is wired, only the selected tab is in the tab order (
tabindex="0", the others-1). - Disabled tabs use
pointer-events: none. A tab that must stay focusable to explain why it's unavailable should usearia-disabled="true"instead.
Don't
- Don't build a row of buttons with a class and call it a tablist.
- Don't put 44px tabs inside a 380px dialog — that's what
.size-smis for.
Notes
The active indicator is an ::after bar at bottom: -2px, so it overlaps the bar's own 2px rail rather than stacking below it. .ck-tab needs position: relative for that, which it has.
The rail inherits --panel-border's low contrast (1.16:1 in light). It's tolerable here because it separates two labelled regions rather than acting as a control's affordance, and the indicator that actually conveys selection is at 12:1.
Pagination
v3.2pagination.mdOne form: a table footer — rows selected, total, rows-per-page, “Page X of Y” and first/prev/next/last. Ported from the v2.0 reference and re-expressed in v3.2 terms: its own version used dead token names, opacity for disabled, a scale(.95) press and off-scale 32px buttons. The numbered page list was removed — the product pages sequentially through tables, so a run of numerals was a pattern nothing used. Nav buttons are .ck-icon-btn.is-outlined and the chooser is a .ck-dd — neither is reimplemented.
What The Two Numbers Count
Selected counts the CURRENT PAGE: rows ticked on this page out of the rows it shows. Total rows counts the WHOLE TABLE, every page — it is the flow’sdata-total, never the page’s row count. Tick a row: the selected count moves, the total does not.| Document | Vendor | |
|---|---|---|
| INV-2026-0411.pdf | Orchard Provisions | |
| INV-2026-0412.pdf | Orchard Provisions | |
| INV-2026-0413.pdf | Orchard Provisions | |
| INV-2026-0414.pdf | Orchard Provisions | |
| INV-2026-0415.pdf | Orchard Provisions |
Bar Form — .ck-pag.is-bar
first page: first/prev disabledThe bar sits on the panel’s bottom edge — always
the footer belongs to the panel, not to the last row. One row, two hundred rows, or still loading: it is in the same place, so the panel’s height never jumps and the buttons never move as the user reaches for them.| Document | Amount |
|---|---|
| INV-90114.pdf | 4,120.00 |
| Document | Amount |
|---|---|
Squeezed, the bar scrolls — it never re-stacks
drag the footer sideways. Wrapping into two lines would change the panel’s height mid-resize and slide the buttons out from under the cursor.| INV-90114.pdf |
| INV-90115.pdf |
The Rows-Per-Page Chooser, Open
the kit's own dropdown overlay — 10 / 20 / 30 / 40 / 50 / 100 / 200, no search rowOutlined nav button — all six states
Pagination
.ck-pag
What It Is
The page list, previous/next, a gap marker, a row count, and a per-page chooser.
Basic Information
One Form: a Table Footer Bar
Rows selected, total, rows-per-page, "Page X of Y", and first / prev / next / last.
Sequential only. The product pages through tables one step at a time, so the bar offers first, previous, next and last — there is no numbered page list.
<div class="ck-pag is-bar">
<span class="ck-pag-info">0 of 100 Row(s) Selected</span>
<span class="ck-pag-total">Total Rows <strong>712</strong></span>
<div class="ck-pag-group">
<div class="ck-pag-rpp">
<span class="ck-pag-rpp-label">Rows per page</span>
<div class="ck-dd size-sm">…</div>
</div>
<span class="ck-pag-page">Page 9 of 24</span>
<div class="ck-pag-nav">
<button class="ck-icon-btn is-outlined size-sm" aria-label="First Page">…</button>
<button class="ck-icon-btn is-outlined size-sm" aria-label="Previous Page">…</button>
<button class="ck-icon-btn is-outlined size-sm" aria-label="Next Page">…</button>
<button class="ck-icon-btn is-outlined size-sm" aria-label="Last Page">…</button>
</div>
</div>
</div>| Class | Role |
|---|---|
.ck-pag.is-bar | the footer bar — 48px min-height, top hairline |
.ck-pag.is-bar.is-compact | nothing selected and no total, so the run collapses right |
.ck-pag-info | "0 of 100 Row(s) Selected" |
.ck-pag-total | "Total Rows 712" — the number in <strong> |
.ck-pag-group | the right-hand cluster |
.ck-pag-rpp / -rpp-label | rows-per-page, holding a .ck-dd.size-sm |
.ck-pag-page | "Page X of Y" — fixed width so it can't jitter |
.ck-pag-nav | the four nav buttons |
The Rows-Per-Page Chooser
Write it as a native <select> and let the kit upgrade it:
<select class="ck-select size-sm" data-ck-dd data-searchable="off"
aria-label="Rows Per Page">
<option>10</option><option>20</option><option>30</option>
<option>40</option><option>50</option><option>100</option><option>200</option>
</select>ClipperDropdown upgrades any <select data-ck-dd> on load into the real overlay dropdown, so a flow never hand-builds the panel and every instance behaves the same. The size class and any inline width carry across the upgrade.
The options are 10 / 20 / 30 / 40 / 50 / 100 / 200. Use this set — a per-screen variation is a defect, because a user who sets 50 on one table expects 50 to exist on the next.
data-searchable="off" is required here. The dropdown turns its search row on at seven or more options, and seven page sizes do not want a search box.
The nav buttons are .ck-icon-btn.is-outlined, a bordered variant of the icon button rather than a pagination-only control. It takes the field stroke set, so it stays in sync with .ck-input and .ck-dd-trigger.
.ck-pag-page has a fixed minimum width so stepping page 9 → 10 doesn't shift the buttons beside it under the user's cursor.
Below 768px the bar wraps: the selected-count drops to its own full-width row and the "Rows per page" label hides, keeping the chooser.
Classes
| Class | Role |
|---|---|
.ck-pag | the bar — the size class goes here |
.ck-pag-info | the range, in tabular numerals |
Sizes
None on .ck-pag. The bar's density comes from its own padding, and the controls inside it carry their own size — the nav buttons are .size-sm (28px) and the chooser is a .size-sm dropdown.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--primary | #013c4b | #e7f9fe | #66d9ef | current page |
--primary-foreground | #ffffff | #05262e | #000000 | current page |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | hover |
--input | #8b9292 | #5f7073 | #999999 | disabled |
--radius-md | calc(var(--radius) - 2px) | — | var(--radius-md) | radius |
States
All six live on the nav buttons — see Icon Button § Outlined. The bar itself is not a control and has none.
Special Rules
The outlined nav button is a declared pairing exemption
At rest it is --muted-foreground on --background — 3.44:1, the same deliberate de-emphasis Empty State carries. A run of four chevrons in a table footer must read quieter than the rows above it; --foreground would make the chrome as loud as the data.
Rules That Matter
- Disable first/prev on page 1 and next/last on the last page — don't hide them. Hiding makes the bar reflow under the user's cursor.
- Keep the option set at 10 / 20 / 30 / 40 / 50 / 100 / 200. A user who sets 50 on one table expects 50 to exist on the next.
.ck-pag-pagehas a floor width so stepping page 9 → 10 can't shift the buttons beside it.- Don't reimplement a button here. Prev/next are Icon Button; the per-page chooser is a
.ck-ddor.ck-select.size-sm. - The bar belongs to the panel, not to the last row. It sits on the panel's bottom edge whether the table holds two hundred rows or one. Put it in a
.ck-table-paneland it stays there; let it follow the rows and it floats up under a short table, which makes the footer look like part of the data. - Show it while the table is still loading. Render the bar with the skeleton rows, with em-dashes where the counts will go. A footer that appears once the data lands makes the panel jump and moves the buttons as the user reaches for them.
- When the panel narrows, the bar scrolls sideways — it does not re-stack. Reflowing into two lines changes the panel's height mid-resize and slides controls out from under the cursor.
Accessibility
- Wrap it in
<nav aria-label="Pagination">. aria-current="page"on the current page — also what the styling keys on.- Prev/next need an
aria-label. - The gap marker takes
aria-hidden="true"and isn't focusable.
Notes
Button min-width tracks the height, so numerals stay square until a label needs more room.
Toggle
v3.2toggle.mdA button whose pressed state is a mode — not a switch, which commits a setting and announces role="switch". On the § 7a control scale; state is aria-pressed so the drawing cannot drift from the announcement.
Segment variant — .ck-toggle-group.is-segment
A small set of mutually exclusive options, all visible at once, drawn as a trough with the chosen one raised out of it. Not tabs — a tab changes what the page is showing, a segment changes the value of a setting. Reaching for .ck-tab here fails loudly: its 16px side padding collapses an icon-only option to zero width.
Icon-only
Labelled, Full Width
Toggle
.ck-toggle
What It Is
A button whose pressed state is a mode — bold, a filter, a view.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-toggle | the button |
.ck-toggle-group | a segmented group, role="group" |
Sizes
.size-sm 28px · default 36px · .size-lg 44px — the standard control scale, because a toggle shares rows with buttons and icon buttons.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--foreground | #05262e | #ffffff | #ffffff | rest |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | hover |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | pressed |
--selected-fg | var(--primary) | var(--primary) | var(--primary) | pressed |
--primary | #013c4b | #e7f9fe | #66d9ef | pressed |
--muted | #f5f5f5 | #1b292d | #1a1a1a | disabled |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | disabled |
--input | #8b9292 | #5f7073 | #999999 | disabled |
Group radius steps inward: --radius-md outside, --radius-sm on the inner buttons.
States
All six: default, hover, active, focus-visible, disabled, pressed.
Special Rules
Alignment is central, in both axes
A toggle is inline-flex with align-items:center and justify-content:center, so its label or glyph sits dead centre. Never let it go lopsided — an off-centre glyph reads as a rendering fault, and in a segmented .ck-toggle-group one misaligned item makes the whole group look broken.
Two things cause it in practice: padding applied on one side only, and a glyph left unsized so it takes the whole box. Both are defects, not styling choices.
Rules That Matter
- A toggle is not a Switch. A switch commits an on/off setting immediately and announces
role="switch"; a toggle is a button. They aren't substitutes — swapping one for the other changes what a screen reader says. aria-pressedis required. It's what carries the state, and it's what the styling keys on, so the drawing can't drift from what's announced. A class alone draws the state without announcing it.- A glyph-only toggle needs a label and a tooltip —
aria-labelfor the action, plus a.ck-tiptooltip.
Accessibility
.ck-toggle-grouptakesrole="group"and anaria-label.
Don't
- Don't use opacity for the disabled state. Use the disabled tokens.
Notes
Heights come from the control scale, and min-width matches the height so a glyph-only toggle is square.
Action Bar
v3.2action-bar.mdTwo grounds: --card for a bar over the page, .on-dark for one over a document or image, which is what the --toolbar-dark family exists for. Buttons inside are .ck-icon-btn; .on-dark retunes their hover pair rather than restyling them. Every icon carries a tooltip that appears on hover AND on keyboard focus — wrap it in .ck-tip-host and the kit does the rest, no controller needed. A glyph-only control without one is a defect (check 21).
Sample — the Document Editor preview toolbar
groups and order follow the app: image transforms on the left, zoom centred, page navigation on the right. The zoom read-out and the page number are the same component — .ck-action-bar-field, a bordered slot, as a span when it is only read and an input when it can be typed into. The page arrows are .size-xs (24px), a step below the bar's 36px controls, because they are secondary to them. .is-split keeps the centre group centred whatever flanks it; a flex row lets it drift once the two sides differ in width. Hover any icon, or Tab to it.The tooltip, on all four sides
hover a button, or Tab to it — these are not forced open. They are spaced so each bubble has room to appear without meeting its neighbour.Floating — .is-floating, lifted off the surface
a selection's bulk actions, floating over the list they act on. It takes the deeper --shadow-overlay, not --shadow-panel: a bar that floats has to read as detached, or it looks like part of the surface it covers. The shipped component is position:fixed at the foot of the viewport — staged here so it can be seen inline.Action Bar
.ck-action-bar
What It Is
A floating run of icon actions over content — a document preview's tools, or the bulk actions for a selection.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-action-bar | the bar |
.on-dark | over a document or image |
.is-floating | fixed, centred, above the content |
.ck-action-bar-label | a page counter or selection count |
Sizes
.size-sm and default — padding only.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--card | #ffffff | #233a3e | #000000 | default |
--card-foreground | #05262e | #ffffff | #ffffff | default |
--panel-border | #ebeff0 | #43575a | #99a7ab | default |
--toolbar-dark | #08272e | — | — | .on-dark |
--toolbar-dark-foreground | #ffffff | — | — | .on-dark |
--toolbar-dark-hover | #2b454b | — | — | .on-dark hover |
--toolbar-dark-active | #395258 | — | — | .on-dark hover |
Radius is --radius-2xl — a bar is a container, not a pill.
.on-dark retunes .ck-icon-btn's two hover properties rather than restyling the button.
States
None of its own. The buttons inside are Icon Button and carry theirs.
Special Rules
Every icon in the bar carries a tooltip
A bar is glyphs and nothing else, so without tooltips it is a row of guesses. Wrap each control in .ck-tip-host — the tooltip then appears on hover and on keyboard focus. See Tooltip § Attaching one.
Show them on hover, not forced open. A specimen row with every bubble pinned visible documents the appearance and hides the interaction — and four adjacent bubbles overlap each other, so it does not even show the appearance honestly. Space the placements apart and let the real hover do the work.
A bar is glyphs and nothing else, so without tooltips it is a row of guesses. Wrap each control in .ck-tip-host — the tooltip then appears on hover and on keyboard focus. See Tooltip § Attaching one.
Floating — .is-floating
position: fixed, centred at the foot of the viewport, with --shadow-raised — x 0, y 1, blur 3. That is the whole lift, and it is the only shadow the bar ever has: at rest the bar casts nothing. It sits in the page, so its border and its --card surface are what separate it.
Do not reach for a deep multi-layer shadow here. An earlier one made the bar read as a modal panel hovering over the page rather than a control attached to it. Every shadow in the system is now that same x 0 / y 1 / blur 3 drop, so there is nothing deeper to reach for.
The canonical case is a selection's bulk actions floating over the list they act on: a count, the actions, a separated destructive action, a labelled CTA and a dismiss.
One labelled CTA, and it needs no tooltip
A bar may carry one labelled control — a plain .ck-btn.primary at the bar's own height, such as "Save". Two things follow:
- It needs no tooltip. The tooltip rule binds on glyph-only controls; this one carries its label already.
- One is the limit. A second labelled button turns the bar into a row of buttons, and the icons beside it stop reading as a set.
Put the destructive action behind its own rule so it is never adjacent to the control a user reaches for most.
.is-split for a centred group
.ck-action-bar.is-split is a three-column grid: leading run, centred group, trailing run. A flex row cannot hold the middle group centred once the two sides differ in width — it drifts as the content changes. Use .ck-action-bar-group with .is-center / .is-end for the three cells.
Sample — the Document Editor preview toolbar
| Group | Contents |
|---|---|
| left | rotate clockwise, rotate counterclockwise, flip horizontal, flip vertical |
| centre | zoom out, 100 %, zoom in, full resolution |
| right | "Pages", the current page (editable), / 12, previous, next |
Groups follow the work, not the chrome: transforms act on the image, zoom acts on the view, navigation moves between pages. Mixing them — a download button sitting beside a rotate — makes the bar a drawer of unrelated actions.
Four things it demonstrates:
- The zoom read-out and the page number are one component.
.ck-action-bar-fieldis a bordered slot: a<span>where the value is only read, an<input>where it can be typed into. They look identical because they are the same kind of thing — a value on the bar. - The page arrows are
.size-xs(24px), one step below the bar's 36px controls, because they are secondary to them. Same reason.size-xsexists at all: a control nested inside another control's context. .on-darkretunes the buttons rather than restyling them — it swaps the two hover custom properties, so.ck-icon-btnstays one definition.- The separator takes
--toolbar-dark-sepon a dark bar, or it vanishes into the surface..ck-divider.is-verticalis retuned by.on-dark, not replaced.
More Document Editor components will land under this section.
Special Rules
Rules That Matter
- It floats and carries elevation. That's what separates it from
.ck-toolbar, which is an in-flow row inside a card. - Over a document or image, use
.on-dark. It swaps in the--toolbar-darkfamily, which was minted for exactly this — a bar sitting on content whose colour you don't control. - A fixed bar must stay clear of what it acts on. A bar covering its own target is a usability defect, not a styling one.
- Every icon on the bar has a tooltip on hover. No exceptions — a bar of unlabelled glyphs is a guessing game.
.ck-tip-hostgives it on keyboard focus too. - Use the standard glyph size and leave real space around it. A control is the glyph plus its buffer: 4px between icons in a cluster, 8px of bar padding. Glyphs pressed against each other or against the bar's edge read as one smear rather than separate actions.
- Put clear space between clusters. 12px between groups against 4px within one, plus a divider. Spacing is what says "these four belong together and that one does not"; the divider only confirms it.
- The resting bar casts no shadow. It sits in the page, so its border and surface are what separate it. Only
.is-floatinglifts, and then by exactly--shadow-md— x 0, y 1, blur 3.
Accessibility
role="toolbar"with anaria-label.- Every glyph-only button needs
aria-labelplus a.ck-tip.
Alert
v3.2alert.mdAn inline banner — not a toast (transient) and not .ck-error-state (replaces the content). Tones are the soft-semantic triplet; text is always --X-soft-foreground.
Alert
.ck-alert
What It Is
A persistent inline banner — something the user should know while they carry on working. Neutral by default, with four semantic tones.
Basic Information
Classes
| Class | Role |
|---|---|
.ck-alert | the banner |
.is-info / .is-success / .is-warning / .is-error | tones |
.ck-alert-icon | 24px box, 16px glyph |
.ck-alert-content | title + message |
.ck-alert-title | 600 / 13px |
.ck-alert-msg | 400 / 12px |
.ck-alert-action | a .ck-btn under the message |
> .ck-icon-btn | the dismiss |
Sizes
.size-sm · default · .size-lg — padding, i.e. density. An alert's height is its content's.
Tokens
| Slot | Tokens |
|---|---|
| neutral fill | --muted + --foreground |
| toned fill | --X-bg |
| toned text | --X-soft-foreground |
| toned stroke | --X-border |
| radius | --radius-2xl — an alert is a container |
The neutral fill is a declared cross-pair: --muted + --foreground is 15.87:1, where the nominal --muted-foreground partner is 3.16:1 and fails AA for body copy. .ck-table th carries the same exemption.
Toned text is always --X-soft-foreground, never the solid --X on its own tint.
States
The banner isn't interactive. The dismiss is .ck-icon-btn.tone-danger.size-xs and carries its own six states.
The component also sets the destructive tone on .ck-alert > .ck-icon-btn, so an alert whose markup omits the class still renders correctly.
Special Rules
An alert is not a toast, and the two are not interchangeable
An alert is an inline message: it lives inside a dialog, a panel, a form or any other container, in the flow of the content it concerns, and it stays until something changes. A Toast Notifications floats above the page, is transient, and dismisses itself.
Building one as the other is a defect in both directions.
The icon on the left and the cross on the right are interchangeable — an alert may carry either, both, or neither. What is not optional is that the title carries the meaning; the tone and the glyph are redundancy.
Rules That Matter
- An alert sits beside content that's still there. A Toast Notifications is transient, floats, and self-dismisses.
.ck-error-statereplaces the content it stands in for. All three exist and none is a substitute for another. - The dismiss is a destructive icon. A cross is destructive wherever it appears, so it carries the destructive tone and the full destructive interaction set — not the alert's own tone. Use
<button class="ck-icon-btn is-close size-xs" aria-label="Dismiss">. - Tone is never the only carrier. The title says what happened.
role="alert"for errors only. It interrupts a screen reader, so informational and neutral banners takerole="status".
Accessibility
- The dismiss needs an
aria-label.
Notes
--warning measures 2.05:1 against the page, so the warning tone's border is weaker than the others. That's inherited from the token set, not local to this component.
Toast Notifications
kittoast.mdEight variants — four feedback (success / error / warning / info) and four action (download / upload / undo / default), taken from the v2.0 molecules reference. Each is the soft-semantic pattern: an --X-bg fill inside an --X-border stroke with the icon on the solid --X. That replaces the leading 3px accent bar this kit had shipped, which read as a hairline on a --popover card and lost the tone almost entirely. Transient and self-dismissing — for anything the user must answer, use a dialog.
Feedback Toasts
successAction Toasts
downloadToast
.ck-toast · .ck-toast-stack
What It Is
Transient, non-blocking confirmation that something happened.
Basic Information
Anatomy
<div class="ck-toast-stack" role="region" aria-label="Notifications">
<div class="ck-toast is-success is-shown" role="status">
<span class="ck-toast-icon">…</span>
<div class="ck-toast-content">
<div class="ck-toast-title">Folder created</div>
<p class="ck-toast-msg">3 linking rules were copied from the template.</p>
</div>
<button class="ck-icon-btn size-xs ck-toast-action" aria-label="Dismiss">…</button>
</div>
</div>The stack is pointer-events: none and each toast re-enables them for itself, so the empty area of the stack never blocks the page beneath it. That's easy to break by adding a background or padding to the stack — don't.
.is-shown and .is-leaving drive the transition; the flow's JS sets them.
Eight Variants
Every one is the soft-semantic pattern: an --X-bg fill inside an --X-border stroke, with the icon on the solid --X.
Four feedback tones:
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--success | #2e9e52 | #42c070 | #44ff88 | .is-success |
--destructive | #e50600 | #fe9b98 | #ff4444 | .is-error |
--warning | #f2a618 | #f7b83d | #ffbb33 | .is-warning |
--info | #387ff9 | #92c4fe | #44aaff | .is-info |
Four action tones:
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--primary-bg | #f0f3f4 | #1f2e32 | #081113 | .is-download |
--primary | #013c4b | #e7f9fe | #66d9ef | .is-download |
--cyan | #0e7490 | #67e8f9 | #67e8f9 | .is-upload |
--violet | #6d28d9 | #a78bfa | #a78bfa | .is-undo |
--muted | #f5f5f5 | #1b292d | #1a1a1a | (none) |
--input | #8b9292 | #5f7073 | #999999 | (none) |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | (none) |
This replaced a 3px leading accent bar, which read as a hairline on a --popover card and lost the tone almost entirely.
Sizes
None. One width, from the stack: min(420px, 100vw - 48px).
A stack of different-width toasts reads as broken rather than as a hierarchy, and a toast is too short-lived to justify a density decision. If a message needs more room than 420px, it isn't a toast.
Special Rules
Rules That Matter
- For anything the user must answer, use a Dialog. For a persistent condition, use an Alert. A toast is for something that already happened.
role="status"announces politely;role="alert"interrupts. Pick by urgency, not by tone class. A success message withrole="alert"cuts the user off mid-sentence.- A toast that auto-dismisses must not be the only record of an error. Auto-dismiss success after 4–6s; never auto-dismiss something the user has to act on.
- It's non-modal and must not be focus-trapped —
ClipperOverlaydoesn't apply. - Max three stacked. A queue of twelve is a log, and that's a Notification Panel.
- The title states what happened — "Folder created", not "Success!".
Accessibility
- The stack is
role="region"with anaria-label, so it's reachable as a landmark rather than an unlabelled div. - The dismiss button needs an
aria-label— it's glyph-only. - WCAG expects the user to be able to extend or dismiss timed content.
Known Defect
--warning measures 2.05:1 against the surface in light theme, under the 3:1 floor for a non-text affordance. It's an amber that's inherently low-contrast on white — a token-set defect, not something to patch here.
Until it's fixed, a warning toast must not rely on its tone alone. Say "Warning" or name the specific condition in the title text.
Notes
What was deliberately not copied from the v2.0 reference, because reuse beats minting:
| Reference had | We use | Why |
|---|---|---|
its own .toast-btn | .ck-btn.size-sm | there's no toast-only button |
its own .toast-progress | .ck-progress.size-sm | there's no toast-only progress bar |
rgba(0,59,74,.06) | --primary-bg | rgba is forbidden, so the tint is pre-composited per theme: #f0f3f4 / #1f2e32 / #081113. Named for the role — the primary family's soft surface — not for the toast |
Its 10px radius isn't copied either: a toast is a panel, and every panel in the kit is --radius-2xl.
Card, Settings Card & Toolbar
kitsurfaces.mdFour surface levels, and they are semantic: --canvas is the ground the app sits on, --background the default surface in it, --card anything elevated but in flow, --popover anything floating. The last three are all #ffffff in light and diverge in dark, so a wrong level is invisible until you switch theme.
Secondary Card — .ck-card.is-secondary
the tinted card surface option: --card-secondary #eaf4f6 light · #2b4348 dark · #122a30 high contrast; text --card-secondary-foreground, stroke --card-secondary-borderMedallion Variant — .ck-card.is-medallion
A card introduced by an icon sitting astride its top edge, for a short list of destinations chosen at a glance. Two things follow from the medallion and are not free choices: the card cannot clip its overflow, and its top padding is 52px rather than a step on the scale, because it is the medallion's radius plus a gap.
whole card is the target — hover, focus and press live on itSurfaces
What It Is
Card · Settings card · Toolbar
Three containers. They hold other components and contribute almost no visual language of their own — which is exactly why getting their surface token right matters: everything inside them inherits the consequence.
Basic Information
Card — .ck-card
<div class="ck-card">
<div class="ck-card-title">Linking rules <svg class="ck-info">…</svg></div>
<p class="ck-card-sub">3 rules, applied in order</p>
…
</div>| Class | Role |
|---|---|
.ck-card | --card surface, --panel-border, --radius-2xl, 16px padding |
.ck-card-title | 600 / 14px, flex row so an .ck-info glyph can ride along |
.ck-card-sub | 400 / 12px in --muted-foreground |
.ck-card-title .ck-info | 14px help glyph in --muted-foreground |
Sizes
Padding only.
| Class | Padding | Reach for it when |
|---|---|---|
.size-sm | 12px | a card inside another card, or a dense grid of small cards |
| (none) | 16px | default |
.size-lg | 24px | a page's primary panel, or a card that is the whole content of a view |
Settings Card — .ck-settings-card
A tinted variant for a settings group, with a ruled title.
| Part | Token |
|---|---|
| surface | --accent + --accent-foreground |
| border | --panel-border |
| title rule | --panel-border |
| radius | --radius-2xl |
| padding | 20px |
No sizes. It's a page-level grouping, so there's nothing for it to align against. Adjust density by sizing the controls inside it.
Inputs, selects, dropdown triggers and tables inside it all set --background explicitly, so their borders stay readable against the tint.
Toolbar — .ck-toolbar
<div class="ck-toolbar">
<div class="ck-search grow">…</div>
<button class="ck-btn size-sm">Filter</button>
<button class="ck-icon-btn size-sm" aria-label="Refresh">…</button>
</div>A flex row above a table or list. .grow on a child takes the remaining space, and flex-wrap: wrap means it degrades to two lines rather than overflowing.
| Class | Gap | Margin-bottom |
|---|---|---|
.size-sm | 8px | 12px |
| (none) | 16px | 16px |
A toolbar is the canonical case for the control height scale. It's where the five-different-heights defect was most visible before the scale existed: a 34px button beside a 35px input beside a 32px icon button. The scale is now 28 / 36 / 44. Put a .size-sm toolbar above a .size-sm table.
Special Rules
Rules That Matter
--cardfor an in-flow panel, never--background.--backgroundis invisible in light theme (both are#ffffff) and reads flat in dark.--accentfor a tinted grouping — never acolor-mixof a badge palette colour.- Anything inside a tinted card that needs its own edge sets
--backgroundexplicitly. A white control on a tinted card is what makes its border readable. - One control size per toolbar, and usually
.size-sm. - Radius does not scale with size. All three containers are
--radius-2xlat every size; only padding changes.
Accessibility
.ck-card-titleshould be a real heading when the card is a titled region, so it lands in the heading outline. The class styles it; it doesn't make it one..ck-infois a help glyph with no text — it needsaria-labelor adjacent text, or it announces nothing.cursor: helpis not an affordance for a screen reader.- A toolbar is a
<div>, notrole="toolbar", unless you implement arrow-key navigation between its controls. The role promises that behaviour. - None of these three is interactive, so none has states. If a card becomes clickable it owes all six.
Notes
.ck-card once had no base rule at all — it existed only as .ck-card-title .ck-info, a descendant selector for an icon inside a title that itself had no rule. Anything marked up with class="ck-card" inherited nothing: no surface, no border, no radius, no padding.
That's the most invisible class of defect in this system, because a page using it doesn't error — it just renders flat, and whoever built the page adds padding in a page <style> block to compensate, which then becomes debt.
The settings-card tint used to be a badge colour — color-mix(in srgb, var(--teal) 5%, var(--background)), with two descendant rules using --teal at 8% and 15%. --teal exists to tint a pill, so a brand change to --accent would never have reached this card, and nobody looking for "the settings card colour" would search for --teal. Same substitution as Member Card, which used the identical mix.
Both container borders sit under 3:1 — the known --panel-border weakness. Tolerable for a container holding labelled content. Worth knowing that in dark theme a card's edge against the page is carried more by the surface difference (--card #233a3e vs page #142226) than by the border.
Member Card
kitmember-card.mdRow actions are hidden until hover, which is fine for a mouse and invisible to a keyboard — so they also reveal on :focus-within. The surface is --accent, not a colour-mix of --teal: --teal is a badge palette colour, and using it as a tint means a brand change to --accent would never reach the card.
Member Card
.ck-member
What It Is
A person in a list — name, email, role pill, and row actions. Used in team and permissions views.
Basic Information
Anatomy
<div class="ck-member-grid">
<div class="ck-member">
<div class="ck-member-av">VS</div>
<div class="ck-member-main">
<div class="ck-member-top">
<div class="ck-member-name">Vaibhav Sharma</div>
<span class="ck-pill size-sm off ck-member-role">Owner</span>
</div>
<p class="ck-member-mail">vaibhav@staple.io</p>
</div>
<button class="ck-icon-btn size-xs" aria-label="Member Options">…</button>
</div>
</div>.ck-member .ck-icon-btn{opacity:0}
.ck-member:hover .ck-icon-btn,.ck-member:focus-within .ck-icon-btn{opacity:1}Sizes
None. A member card is a list item read against its siblings, not a control in a row. Density comes from the grid: .ck-member-grid is 2 columns, .cols-1 for a narrow panel or drawer.
The avatar is fixed at 36px.
Special Rules
Rules That Matter
min-width: 0on.ck-member-mainis not optional. Name and email both truncate with an ellipsis; without it flex refuses to shrink and the row overflows.- Hover-reveal always needs a focus equivalent. The row action is revealed with
:hoverand:focus-within— with hover alone, a keyboard user can focus a completely invisible button atopacity: 0. - The role pill is
.size-sm. A default 24px pill next to a 13px name crowds the top row. - Name the person in the action's label — "Options for Vaibhav Sharma". "Options" repeated twenty times down a list is useless in a screen reader's element list.
- The card is not interactive as a whole. If it becomes clickable, the nested action button becomes a nested interactive control — which needs restructuring, not styling.
Accessibility
- The avatar's initials are decorative when the name is adjacent, so
aria-hiddenis correct there. Use initials as the fallback, never an empty circle.
Known Defect
The email is at 4.44:1 in light theme, which fails AA by 0.06. --muted-foreground does not clear 4.5:1 against --background (3.44), nor against the slightly darker --accent tint.
This is the general hazard of a tinted surface: a foreground verified against the page ground is not automatically valid on a tint. The fix is a token revision, not a local override — so until then, don't put uniquely-identifying information only in the email line.
Navigation List
v3.2navlist.mdA vertical list of destinations inside a page — the sections of Settings. Not the sidebar rail, which is the app's own edge: that one is icon-only, global, and there is exactly one of it. The chosen row insets itself so it reads as a lozenge lifted out of the list rather than a band welded to the panel edge.
Variant 1 — with a leading icon
.ck-navlist — the default. A glyph identifies the section before the label is read.Variant 2 — without a leading icon
.ck-navlist.is-plain — the row keeps its height, so a list of each lines up with the other. There is no third variant.All Six States
Anatomy
| Part | Class | Notes |
|---|---|---|
| panel | .ck-navlist | fills its column, --card, 16px radius, 1px --panel-border |
| row | .ck-navlist-item | 44px minimum — the large step on the control scale |
| leading glyph | .ck-ico | 16px in a 24px box; hidden by .is-plain |
| chevron | .ck-navlist-chevron | always present, pushed to the trailing edge |
States
| State | Surface | Marks |
|---|---|---|
| Default | --card | --icon-color |
| Hover | --hover-bg | --icon-color |
| Pressed | --selected-bg | --icon-color |
| Focus | unchanged | --focus-ring |
| Selected | --primary | label, glyph and chevron all --primary-foreground |
| Disabled | transparent | --muted-foreground, cursor:not-allowed |
Tokens
| Token | Value | Where |
|---|---|---|
--card | #ffffff | the panel surface — white in light, the elevated surface in dark |
--panel-border | #ebeff0 | the panel stroke |
--primary | #013c4b | the selected lozenge |
--primary-foreground | #ffffff | every mark on it — label, glyph, chevron |
--hover-bg | #e7f9fe | hover |
--selected-bg | #e5f8fe | pressed |
--icon-color | #5a7278 | the glyph and chevron at rest |
--muted-foreground | #5a7278 | disabled |
Rules
- The chevron is always there, and it turns clockwise both ways. Closed it points down; open it points right. Opening is three quarters of a turn, closing is the last quarter, and the pair completes one revolution — so the control never unwinds.
- The panel fills its column, on
--card. A nav that stops where its items stop leaves the page ground showing below it and reads as a card that ran out rather than the side of the screen. - Selected turns every mark white. On the
--primarylozenge the label, the leading glyph and the chevron all read--primary-foreground. A glyph left on--icon-colorthere measures 2.35:1. - Two variants, no third. With a leading glyph or without. The row height does not change between them.
- It is not the sidebar rail. The rail is the app's own edge — icon-only, global, one of it. This is a list of sections inside a page.
- Long names truncate from the middle and carry a tooltip, per the universal rule.
Empty State
kitempty-state.mdStructure, not just centred text: a glyph, a title, a line of explanation, and one action. An empty state with no action is a dead end. The dashed outline is what tells it from a loading shimmer at a glance.
Empty State
.ck-empty
What It Is
What a container shows when it has nothing in it.
Basic Information
Anatomy
<div class="ck-empty">
<span class="ck-empty-icon"><svg …></svg></span>
<div class="ck-empty-title">No linking rules yet</div>
<p class="ck-empty-msg">Rules decide which documents get matched together.</p>
<button class="ck-btn primary ck-empty-action">Add a rule</button>
</div>| Class | Role |
|---|---|
.ck-empty | centred column, dashed --input border |
.ck-empty-icon | 32px glyph in --input |
.ck-empty-title | 600 / 14px in --foreground |
.ck-empty-msg | body copy, max-width: 44ch |
.ck-empty-action | the one thing to do next |
Sizes
Padding, not a box.
| Class | Padding | Gap | Reach for it when |
|---|---|---|---|
.size-sm | 20px 12px | 6px | inside a small container — a dropdown list, a panel section, a narrow table |
| (none) | 36px 16px | 8px | default — a card, a tab panel, a drawer body |
.size-lg | 56px 24px | 12px | a whole page or view with nothing in it: first-run, or a cleared search on a full-screen list |
Icons
Lucide, 16×16, stroke-width 2, currentColor.
Special Rules
Rules That Matter
- An empty state with no action is a dead end. It tells the user there's nothing there without telling them how to change that. Always give it one action, and make it the obvious next step.
- The dashed border is what distinguishes "empty" from "still loading." A skeleton has a filled shimmer; an empty state has an outline. Don't swap them.
- The title names what is missing — not "Nothing here" — and the message says why it matters rather than repeating the title.
- At
.size-sm, drop the message. Icon and title only. Four stacked elements in 20px of padding reads as cramped, not compact.
Accessibility
- The title should be a real heading (
<h2>/<h3>) when the empty state replaces a titled region, so it appears in the heading outline. - The icon is decorative —
aria-hidden="true". - If the container became empty after an action — a filter cleared it, a delete emptied it — announce that in a live region. The empty state appearing is a visual change only.
- The action is a real
.ck-btn, not a link styled as one.
Don't
- Don't use a dashed border for loading. Use a skeleton.
- Don't use
.size-lginside a card.
Notes
max-width: 44ch on the message is deliberate: centred text past about 45 characters per line becomes hard to track back to the start of the next line.
.ck-dd-none, .ck-menu-empty and .ck-table-empty are separate one-line empty messages inside their own components. They're intentionally not folded in here — each is a single line in a constrained container and doesn't need the icon/title/action structure. If any grows past one line, it should become a .ck-empty.size-sm.
Empty, Loading & Error
kitstates.mdThree distinct answers to "there is nothing to show yet", and they must not be swapped: a shimmer in the shape of the content that is coming, a dashed outline saying there is none plus a way to change that, and a destructive-tinted panel with a retry.
| Invoice | Supplier | PO | DO | Status | Due | Currency | Amount |
|---|---|---|---|---|---|---|---|
Empty, Loading and Error States
What It Is
Three distinct answers to "there is nothing to show", and they must not be swapped.
Basic Information
Skeleton Primitives
| Class | Shape |
|---|---|
.ck-skeleton-text | 12px line |
.ck-skeleton-title | 16px line, capped at 40% width |
.ck-skeleton-avatar | 20px circle |
.ck-skeleton-ico | --icon-box (24px) rounded box, so it stands in for an icon without resizing the row |
.ck-skeleton-image | 16:10, panel radius |
.ck-skeleton-pill | 72 × 24px, --radius-full |
.ck-skeleton-btn | 88 × 36px, control radius |
.ck-skeleton-input | full width × 36px |
.ck-skeleton-list + .ck-skeleton-row | a list of rows |
<div class="ck-skeleton-list">
<div class="ck-skeleton-row">
<div class="ck-skeleton ck-skeleton-avatar"></div>
<div style="flex:1">
<div class="ck-skeleton ck-skeleton-title"></div>
<div class="ck-skeleton ck-skeleton-text"></div>
</div>
<div class="ck-skeleton ck-skeleton-pill"></div>
</div>
</div>That renders as a recognisable member row rather than three grey bars — which is the whole difference between a skeleton and a placeholder.
Table Loading
<table class="ck-table is-loading size-sm">
<thead>…</thead>
<tbody><tr><td><div class="ck-skeleton ck-skeleton-text"></div></td>…</tr></tbody>
</table>.is-loading keeps the cell padding and the row height, so the column widths hold and the header doesn't jump when the real rows arrive. A skeleton that reflows on resolve is worse than a spinner.
Panel Loading
<div class="ck-card ck-loading">
<div class="ck-loading-overlay">
<span class="ck-spinner" role="status" aria-label="Loading"></span>
<span class="ck-loading-label">Loading documents…</span>
</div>
</div>.ck-loading sets a min-height so the page doesn't reflow when it resolves, and border-radius: inherit on the overlay clips it to whatever panel it's in.
Use this only where the panel's shape is unknown — a chart, a preview. Where the shape is known, a skeleton says more.
Images
| Class | Use |
|---|---|
.ck-image | a real <img> — 16:10, object-fit: cover, --muted while it loads |
.ck-image-empty | no image available: dashed outline, icon, one line |
.ck-skeleton-image | still fetching |
.ck-image is a real element rather than a background, so alt text and the browser's broken-image fallback both work.
Radius
--radius-2xl for .ck-empty, .ck-error-state, .ck-image and .ck-image-empty. .ck-skeleton uses --radius-sm, and .ck-skeleton-avatar --radius-full.
Tokens
The Three States
| State | Class | Treatment | Says |
|---|---|---|---|
| Loading | .ck-skeleton*, .ck-loading-overlay | filled shimmer in the shape of the content | "it's coming" |
| Empty | .ck-empty | dashed --input outline, icon, title, one line, one action | "there is none, and here's how to change that" |
| Error | .ck-error-state | --destructive-bg fill, --destructive-border, a retry | "it failed, and you can try again" |
The shimmer runs --muted → --skeleton-sheen → --muted, and the spinner's track is --skeleton-sheen with a --primary leading edge. Skeletons and spinners share one tone family on purpose — they appear together, and two different greys read as a bug.
--skeleton-sheen is deliberately quiet. A loud shimmer competes with the real content arriving next to it.
Special Rules
Rules That Matter
- Every component that can hold absent, arriving or failed content needs all three states. Most components in this kit shipped with none.
- Dashed outline means empty; a filled shimmer means loading. That distinction is what tells the two apart at a glance. Don't swap them.
- An empty state with no action is a dead end. An error state that only apologises is the same defect.
- Announce the change. A panel emptying, resolving or failing is a visual change only — put the result in a live region. This is the most commonly missed part of all three states.
- **Build a skeleton to the shape of what it replaces,** not as a grey rectangle.
Accessibility
.ck-spinnerneedsrole="status"and anaria-label. A bare rotating div announces nothing.- Skeletons are decorative (
aria-hidden="true"), and the region they fill carriesaria-busy="true"while loading. - An error state's retry is a real
<button>, and the message should say what failed, not just that something did. - Under
prefers-reduced-motionthe shimmer flattens and the spinner slows — neither is removed, because removing them would leave no indication that anything is happening.
Where all three are required
Tables, lists, cards, panels, images, avatars, dropdown and menu option lists, folder trees, member grids, search results, and any icon whose glyph depends on fetched state.
Notes
Some components have a one-line empty message rather than the full .ck-empty structure — .ck-dd-none, .ck-menu-empty, .ck-table-empty. That's intentional: each is a single line in a constrained container. If one grows past a line, it becomes a .ck-empty.size-sm.
Canvas layout — panels on the ground
kitcanvas.mdThe blank white panel a screen’s content actually lives in, laid on the canvas. It is not a card: a card is a discrete object in a list of objects, a panel is a region of the screen. Same surface, different job — which is why its rules are all about how panels sit next to each other rather than what they hold.
The Container Inset — 16px On All Four Sides
One property, every container:--container-inset. The tinted block is the content box, so the inset reads as an even frame on all four sides. Each of these used to pick its own number — 8, 18/20, 0/12/12, 12/16/16, 20 — and five were asymmetric, so content sat closer to one edge than the other for no stated reason. .size-sm is 12, .size-lg is 24, and .is-flush is 0 for content that must reach the edge.Every Container’s Inset
Each container takes one tier of the ladder, on all four sides (a bar: block / inline). A component never invents a number.| Tier | Token | Value | Components | Why this tier |
|---|---|---|---|---|
| xs | --container-inset-xs | 4px | .ck-folder-row, .ck-folder-doc (folder navigator rows); .ck-menu frame, and a menu list under a header or search | a row inside a list, or the frame around a list of rows: the row's own height sets the rhythm |
| bar-sm | --container-inset-bar-sm | 8 / 12px | .ck-alert.size-sm; .ck-accordion.size-sm trigger | a one-line bar, small |
| bar | --container-inset-bar | 12 / 16px | .ck-toast; .ck-alert; .ck-notif-head; .ck-accordion-trigger | a one-line bar whose height is its own |
| bar-lg | --container-inset-bar-lg | 16 / 24px | .ck-alert.size-lg; .ck-accordion.size-lg trigger | a one-line bar, large |
| sm | --container-inset-sm | 12px | .ck-card.size-sm; .ck-menu-header; .ck-col-card; .ck-filter-rule; .ck-dfp-body; .ck-activity-item | read close up and in bulk: a card in a list, a fields panel |
| base | --container-inset | 16px | .ck-card; .ck-panel-head / -body / -foot; .ck-dialog-head / -foot; .ck-drawer-head / -tools / -body / -foot; .ck-accordion-panel; .ck-member; .ck-settings-card; .ck-feature; .ck-connector; .ck-form.is-carded; .ck-tab-panel | the primary surface of what you are looking at |
| lg | --container-inset-lg | 24px | .ck-card.size-lg | a spacious, standalone card |
| Component | Inset | Why |
|---|---|---|
| .ck-empty, .ck-dropzone | 36 / 16 and 32 / 16 | a placeholder centred in the space it fills; the block inset is the breathing room around the message |
| .ck-tip | 8 / 10 | a compact label, not a container you read inside |
| .ck-action-bar | 8 | a tray of 36px controls; the controls set its height |
| .ck-auth | 32 | a page-level card standing alone on the canvas |
Full screen — header, main nav, canvas, three panels
the left navigation runs the full height of the page and the header bar starts to its right — the rail is the app’s fixed edge, while the header belongs to the page inside it and changes as you navigate. The header is transparent, so the canvas runs behind it and its bottom rule is what separates it from the first row of panels. Both are always present unless a screen has an explicit reason to hide them. Everything below the header is.ck-canvas-area; each white region is a .ck-panel.Panels You Can Size — .ck-panel.is-resizable
drag my right edge — handle forced visible here
I absorb the difference
so do I
Drag the first panel’s trailing edge. It stops flexing and holds a width; the two beside it keep flex:1 and share the difference. The ceiling is not a fixed number — it is the row minus every sibling’s own --ck-panel-min floor, recomputed on each drag, so a panel can never crush the one next to it. Narrow the window and the panels shrink with it rather than overflowing; below 900px the row stacks and the widths are dropped, because a row of sized panels on a phone is a row of unreadable columns.
The Four Spacing Rules
12px between panels, always, whenever more than one is on screen. 0 at the top — the first row meets the header bar with no gap, because the header is the page’s own edge and a strip of canvas between the two reads as a rendering gap. 12px at the bottom, above the base footer. 12px at the sides, so the gutter matches the gap and the rhythm is one number everywhere.the asymmetry at the top is deliberate, and it is the rule most likely to be “corrected” by someone tidying up. It is not a mistake.Why the edge is --panel-stroke and not --panel-border
--panel-border measures 1.02:1 against the canvas — no edge at all, so a panel was read only by its white fill at 1.13:1. --panel-stroke is 1.63:1 against the panel and 1.44:1 against the canvas: evident, without becoming a hard rule around every region of the screen. In high contrast it carries the whole panel, because there --card and --canvas are both black.No shadow. A panel rests on the page; only an overlay lifts (§ 14.3 check 21). And the fill is --card, not a literal white — white would black out in dark theme.Grid Layout
v3.2grid.mdThe columns every layout sits on — 4, 6, 8, 10 and 12 — and the lines that show them. Each grid below has its lines on: the tinted bands are columns, the fine rules are the 8px horizontal lines. Anything that should share a line either sits on it or is a finding.
An Organism On The Lines
A panel laid on the 12-column grid with the lines on: the icons, the names, the badges and the buttons each share one vertical line, and every row's items share one horizontal line. Turn the lines off to see the finished layout.4 Columns — .ck-grid.cols-4
narrow panels, a drawer, a phone6 Columns — .ck-grid.cols-6
a card grid, a settings page8 Columns — .ck-grid.cols-8
a main area beside a side panel10 Columns — .ck-grid.cols-10
a wide form with a margin12 Columns — .ck-grid.cols-12
the full canvas — the defaultRules
- Everything sits on a line. Draw the grid's lines behind a layout: items in a row share one horizontal line through their centres (text by its ink), and the parts of a column share one vertical line — the container's inset.
- Measure it, then look.
scripts/audit_grid_lines.pydraws both sets of lines in a browser and reports anything more than 1px off;[data-ck-grid-lines]shows the same lines by eye. - Every panel and organism is laid on a grid — 4, 6, 8, 10 or 12 columns, gutter
--grid-gutter(the canvas gap). A new one is checked on the lines before it enters the kit. - Spans, not widths. A part takes
.span-Ncolumns; a fixed pixel width that ignores the columns is a finding. - Under 720px every grid is 4 columns and wide spans take the whole row.
Tokens
| Token | Value | For |
|---|---|---|
--grid-gutter | 12px (--canvas-gap) | between columns and rows |
--grid-baseline-step | 8px (--space-sm) | the horizontal line spacing in the overlay |
--grid-guide | #cde9f0 · #35565c · #1f4a55 | the column tint in the overlay (light · dark · high contrast) |
--grid-baseline | #e3eaec · #2a3e42 · #262626 | the horizontal lines in the overlay |
Header Bar
kitheader-bar.mdOne markup definition, built by ClipperHeader in clipper-kit.js?v=3ce65335 — a per-page copy is how the header drifts between flows. The breadcrumb trail is a first-class part of it, tokenised as --breadcrumb-gap and --breadcrumb-size.
--canvas ground with no frame around them, on purpose — so everything you can see belongs to the bar itself. They used to sit in a bordered white card, and that card’s 1px edge and white fill read as the bar’s own chrome when neither was. The bar is background: transparent in every mode, so on a white sheet it looks exactly like a white bar and the change is invisible — against the canvas you can see the ground running behind it. It has no chrome at all — no fill, no border, no shadow. What separates it from the content below is that content’s own top edge, which is exactly why the first row of panels meets it with a 0 gap.side gutters are a fixed 16px, matching the gap the canvas puts either side of its panels, so the header’s content lines up with the panel edges directly beneath it. At 8px the breadcrumb sat half a gutter inboard and the page read out of register.Settings Menu — the gear
Built by ClipperHeader under the gear — click it in the bar above. One list, in this order: Personal Information, Team Members, Role Management, Company Information, Subscription Billing, Help and Support. Picking an item firesck:settings with its key; the flow decides what opens.Plain — .header-bar.is-plain
a title and the trailing controls, no trail. For a page that is the destination — a dashboard, a settings root, a single-level view. Reach for the breadcrumb only when a user can be three levels deep and needs to know where; on a flat page it is chrome that says nothing. 48px, because one line of title needs no more.The panel toggle reflects state — it is not a back button
Header Bar
.header-bar · .header-left · .breadcrumbs
What It Is
The same bar on every screen, tweaked per situation through attributes and tokens — never by rewriting the markup in a page.
Basic Information
Anatomy
The kit owns the leading run and nothing else:
[ panel open/close control ] [ breadcrumb trail OR title ] [ info icon ]<header class="header-bar">
<div class="header-left"
data-crumb-header
data-crumbs="Purchase Orders|Pending|Folder Settings"
data-info="Optional tooltip text"
data-panel="#folder-panel"></div>
<div class="header-right"> …the flow's own buttons, untouched… </div>
</header>Sizes
No size variants — it's one bar per screen, so there's nothing for it to align against. Density is a token decision:
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--foreground | #05262e | #ffffff | #ffffff | the title and the active crumb |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | inactive crumbs, the separator, and an idle icon button |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | crumb and icon-button hover |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | crumb pressed |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | focus, on the crumbs and the controls |
--primary | #013c4b | #e7f9fe | #66d9ef | the avatar plate and the notification badge |
--primary-foreground | #ffffff | #05262e | #000000 | their text |
--radius-md | calc(var(--radius) - 2px) | — | var(--radius-md) | the icon buttons |
--radius-sm | calc(var(--radius) - 4px) | — | var(--radius-sm) | a crumb's hover pad |
--radius-full | 999px | — | — | the avatar and the notification badge |
--breadcrumb-size | 16px | — | — | crumb and separator type |
--breadcrumb-gap | 4px | — | — | crumb ↔ separator spacing |
--header-control-gap | 8px | — | — | toggle ↔ trail ↔ info spacing |
The three sizing tokens and the radii carry no colour, so they show no chip — that is correct, not a gap. --radius-2xl used to be listed here as “the bar”; the bar has no radius at all, because it spans the full width and rounding one edge of a full-bleed bar reads as a rendering error.
The separator reads --breadcrumb-size too, so the two stay in step; a glyph separator uses .breadcrumb-sep .ico at 1em for the same reason.
The panel toggle and info icon are .ck-icon-btn, so they are on the control height scale. In a compact header, use .ck-icon-btn.size-sm (28px) and drop --breadcrumb-size to 14px together — changing one without the other leaves the glyphs and the text on different optical sizes.
Radius
None on the bar — a header bar is a strip, not a panel. Crumb items are --radius-sm; icon buttons --radius-md.
Special Rules
Rules That Matter
- Never hand-roll the leading run. One
data-crumb-headerhost, and the module builds it. A per-page copy is how the header drifts between flows. .header-rightbelongs to the flow, not the kit. Each flow wires its own settings menu, profile menu and theme switcher there. The kit stops at the leading run on purpose — replacing that group would break those handlers.- The leading control is a panel toggle, not a back button. There is no back button in the header bar.
data-crumbsfor a path,data-titlefor a destination — never both.- Tweak density with tokens, not per-page CSS.
Attributes — this is the tweak surface
| Attribute | Effect |
|---|---|
data-crumb-header | marks the host. Required; the module skips anything without it |
data-crumbs | pipe-separated trail. The last entry renders as current |
data-title | a single label instead of a trail |
data-info | tooltip text on the info icon. Omit the attribute to omit the icon; leave it empty for an icon with no tooltip |
data-panel | selector for the panel the control collapses. That element gets is-closed toggled. Omit it and the control still renders and still fires the event |
data-toggle="off" | suppress the panel control on pages that already ship their own wired one — rendering a second would duplicate the affordance or silently replace a working handler |
Event: clipper:panel on document, detail { open: boolean }.
Trail and title render into the same slot with the same metrics, so the header's shape doesn't change between them.
The Panel Toggle
Its glyph reflects state — panel-left-close when the panel is open, panel-left-open when it's shut. A fixed chevron reads as the wrong affordance the moment the panel is closed.
ClipperHeader in clipper-kit.js?v=3ce65335 owns this: it swaps the glyph, keeps aria-expanded in step, toggles is-closed on the panel named by data-panel, and emits clipper:panel. Don't hand-roll it.
Accessibility
- The trail is a
<nav aria-label="Breadcrumb">. The last crumb carriesaria-current="page"and is a<span>, not a link. - The panel control reflects state in all three places: glyph,
aria-label, andaria-expanded. - The info icon needs an
aria-label— it has no text.
Notes
The breadcrumb trail is part of the header bar. Build it. An earlier version of this documentation carried a hard prohibition — "never reintroduce a breadcrumb into the header bar" — which was wrong. It was almost certainly a decision about one specific header during the nav revamp, recorded as a universal ban. The kit contradicts it in three places: clipper-kit.js?v=3ce65335 builds the trail, clipper-kit.css?v=8da91cd1 styles it in nine rules, and design-system.css?v=7ab031ca defines --breadcrumb-gap and --breadcrumb-size as first-class tokens.
Two things still open:
- The breadcrumb is now its own component — see Breadcrumb.
.ck-breadcrumb*are the canonical names; the unprefixed ones stay as aliases..header-barand.header-leftare still unprefixed, so they remain flow CSS living in the kit. Either prefix them or move them out. - No reference page uses
data-crumbs, so the breadcrumb path is shipped but undemonstrated — which is part of why a wrong rule about it went unchallenged for so long. - Crumb hit areas are
4px 6pxpadding on 16px text, which is small. Worth revisiting against the 24×24 target floor.
Main Left Navigation
kitsidebar-nav.mdThe app’s main left navigation, rebuilt from the shipped sidebar-nav.html. One variant, and there is no other — no round/square switch, no expanded mode, no size scale. A product has exactly one main navigation, so options here only invite two screens to disagree about what the app’s edge looks like.
The rail — one variant, live
hover a button. The last two — Workflow and Compliance — open a panel of pages; the first three are destinations and show only a tooltip. Pin the panel to lock it into the layout.Every Interaction
tooltip on a destination button — the kit’s.ck-tip in its .is-end placement, hung off the button as a .ck-tip-host. That brings :focus-within with it, so a keyboard gets the label too, and it carries no shadow.left: 57px with --shadow-md. The tooltip is suppressed while it is open: the panel already names the section, and two labels for one thing is noise..is-locked takes it from absolute to in-flow and drops the shadow, so the workspace resizes around it instead of being covered. That is why the pin is a toggle with aria-pressed and not a close button.What the rail is made of
56px column · 16px/8px padding · 12px between blocks, 4px inside one · 40px round buttons on a 20px glyph · a full-width hairline between runs · the tenant logo at the foot.the 12/4 split is the whole grouping mechanism — a run reads as one object because its buttons are three times closer to each other than to the next run. That is why no run needs a label in a rail this narrow.hover is--hover-bg, never grey. The reference hovers to --muted; the kit’s protocol is that nothing hovers to grey, and --hover-bg is the same tint the active state uses — so hover reads as “on the way to selected” rather than as a different idea.the tenant logo takes no tone of its own: a customer’s brand colour is not ours to place on a semantic surface. And the reference’s open/close toggle at the foot is deliberately absent — the pin already owns that state.Folder Navigator
kitfolder-nav.mdTwo variants. .ck-folder-nav is the default everywhere in the product — folder listing, folder settings, the reconciliation flows: orange counts and two levels. .ck-folder-nav.is-document is the document editor: blue counts and a third level of documents with checkboxes. The two colours are a real distinction — in the editor a count is pages loaded here, in a listing it is folders needing attention. Rebuilt from scratch on the kit’s own atoms: the search is .ck-search, the checkbox is .ck-check, the count is .ck-counter and every icon-only control carries a tooltip. Both trees are live — click a folder to select it, a chevron to open it without moving the selection, and type in the search to filter.
A Long Nested List Scrolls
The Folders Below Stay Reachable. A folder with sixty documents used to push every folder under it off the bottom of the panel. Nothing was lost — the outer tree scrolls — but the reader had to scroll past one folder’s contents to reach the next, and the tree stopped reading as a list of folders. The partial row at the fold is deliberate: a row cut by the edge says there is more, where a clean edge looks like the list ends. overscroll-behavior:contain is the load-bearing part — without it, hitting the bottom of the inner list hands the gesture to the outer tree and the whole panel lurches.
No folders match that name.
No documents match that name.
No folders match that name.
Folder Navigator
A folder tree in a panel, sitting beside the thing it navigates.
Core Behaviour
Clicking a folder selects it and the panel beside it changes. Clicking a chevron opens that folder without moving the selection. Typing in the search hides rows that do not match and opens every folder still holding one.
The Two Variants
| Class | Where It Is Used | Levels | Counter |
|---|---|---|---|
.ck-folder-nav | Everywhere else — folder listing, folder settings, the reconciliation flows | Folders, any depth | amber |
.ck-folder-nav.is-document | The document editor | 3, the third being documents with checkboxes | blue |
The two counter colours are a real distinction, not decoration. In the editor a count is *pages loaded here*; in a listing it is *folders needing attention*. Two different facts should not wear one colour.
Levels
Every level of the tree is a folder. There is no workspace level — the concept is gone from the system — so nothing in the tree is named, drawn or created as one. In the editor the level below a folder is a document.
| Level | What It Is | Glyph |
|---|---|---|
| Any | Folder | Standard: one folder glyph at every level, in every state. Editor: folder / folder-open |
| Below a folder (editor) | Document | none |
Folder Row (Standard)
Every folder row in the standard variant carries three things at its trailing edge, in this order from the left:
| # | Part | Class | When It Shows |
|---|---|---|---|
| 1 | Horizontal kebab — the folder's actions | .ck-folder-tool.is-more (ellipsis) | On hover or focus |
| 2 | New Folder — a folder inside this one | .ck-folder-tool.is-newfolder (folder-plus) | On hover or focus |
| 3 | Amber counter | .ck-counter.amber | Always |
The kebab reports ck:folder-more with the folder and its button; the page opens its own menu against it. New Folder reports ck:folder-create with the parent folder.
Creating a Folder
The head's primary plus (.ck-icon-btn.is-add.is-primary, tooltip “New Folder”) opens the Create Folder modal directly — no menu of choices in between, because a folder is the only thing the navigator creates. Point the plus at the dialog with data-ck-folder-new="#id"; the dialog is a kit .ck-dialog inside a .ck-dialog-scrim hidden, holding one Folder Name field, Cancel (data-ck-close) and a primary Create Folder (data-ck-folder-create-submit). The kit opens it through ClipperOverlay: focus lands in the name field, Escape, the scrim and Cancel close it — no ✕, because Cancel is there, and focus returns to the plus. Create reports ck:folder-create with the name.
Rules That Matter
- Every part is an existing atom. The search row is
.ck-searchwith a.ck-input, the document checkbox is.ck-check, the header actions are.ck-icon-btninside a.ck-tip-host, and the count is.ck-counterwith a palette tone. The tree contributes layout and nothing else. The two shipped versions this was rebuilt from had drifted into three different search inputs and two checkbox sizes between them; pointing the tree at the atoms is what stops that happening again.
- A document row carries no icon and no chevron lane. The checkbox already says what the row is, and a file glyph on every one competes with the folder glyphs the eye uses to read the tree's shape. A document can never expand either, so reserving a chevron lane for it is 23px of nothing in front of every row — and the selection highlight draws that emptiness. Dropping the lane also lands the checkbox exactly under the parent folder's icon, which is the indent rule below. Folders keep both; documents keep neither.
- A nested row begins under its parent's folder icon, not its chevron. The indent is therefore
calc(var(--icon-size) + var(--ck-row-gap))— derived, not guessed. A flat 20px leaves every level a few pixels adrift of the glyph above it.
- One folder glyph, at every level, in every state (standard). A folder is a folder however deep it sits and whether or not it is open — the chevron already says that. In the editor a selected folder shows the open glyph, whether or not it expands: a leaf has nothing to expand, so keying the open icon only off the expanded state draws the very row the reader is looking at as shut.
- The sort control opens a menu, it does not cycle. Two options, A to Z and Z to A, each naming the order it produces and each carrying its own icon; the current one is
aria-checked. A button that silently changes an invisible setting gives the reader no way to know what it did. Sorting is applied within each parent, never flattened — a tree sorted flat tears children away from their folders.
- There is no workspace. The tree holds folders and, in the editor, documents — nothing else. Do not reintroduce a top level with its own glyph, its own create option or its own wording.
- Creating is one button, straight to the modal. The head's primary plus opens the Create Folder modal; there is no overlay menu of options, because there is only one thing to create. Every folder row also offers New Folder on hover, which needs no choice — the row already says where it goes.
- Only the standard variant creates anything. The document editor's tree is a picture of what is open, not a place to build one, so
.is-documentcarries no create control at any level and no refresh in its head. Refresh belongs to the standard variant, where the folder list is the thing that goes stale.
- The chevron turns clockwise both ways. closing runs 90° → 180° rather than rewinding. The integrations screen collapses with
rotate(-90deg), which turns the opposite way to every other disclosure in the kit — § 14.9 check 7 calls that a defect, so it was not carried over.
- The count is information, so it never hides, and it never moves. It sits permanently at the trailing edge. The row's controls come *before* it in the markup and the count takes the auto margin, so the gap opens ahead of the count and the count stays put. Put the controls after it and every count in the tree jogs sideways under the pointer.
- Every folder row shows its amber count, at every level. A parent's count is a fact about that folder, the same as a leaf's, so the standard variant does not hide it on a group heading. In the editor the count is the pages loaded under that folder.
- Row controls reveal on
:focus-withinas well as:hover. They animate fromwidth: 0, so the counter slides instead of the row reflowing. A control that only appears on hover cannot be reached by keyboard, which § 14.3 check 31 does not allow.
- A leaf FOLDER still reserves the chevron's lane. Hide it with
.ck-folder-chev.is-empty, never by omitting the element — drop it and the label starts 23px left of its siblings' and the folder column goes crooked. This applies to folders only: a document has no siblings that expand, so it has no column to hold.
- The highlight begins at the row's leftmost icon, not at its box. A reserved-but-empty chevron lane would otherwise put 23px of colour in front of the first thing the reader can see. The row cannot simply move — its edge *is* the highlight's edge, so shifting it would carry the icon along and break the column — so the paint goes on a
::beforeinset by one chevron plus one gap. What is left in front of the icon is the row's own 8px padding, the same lead every other row has, so every highlight shares one rhythm. Hover, selection and the focus ring all follow it.
- Selection is one row; document checkboxes are a set. Selecting is a cursor, so a new selection clears the old one. Checkboxes govern which documents are *open*, which is genuinely multi-select and independent of where the cursor sits.
- A group row in a listing only toggles. In the editor it toggles *and* selects, because a folder there is itself a destination.
- Search restores the tree it found. Clearing the field puts every folder back to open or closed as the reader left it. A search should not quietly reorganise someone's tree.
- Hover is
--hover-bg. Never grey, per check 24.
- The collapse control lives outside the panel and names it by id through
data-ck-folder-collapse. A toggle inside would become unclickable the moment it closed the thing it sits in.
- One round button in the head, at most. The primary add is round because it is the head's single affirmative action; sort, settings and refresh are plain
.ck-icon-btnon the control radius.
Classes
| Class | What It Is |
|---|---|
.ck-folder-nav | The panel. 300px, --radius-2xl, --panel-stroke |
.is-document | The editor variant — blue counts, three levels |
.is-closed | Collapsed. Width animates to 0; the panel stays mounted |
.ck-folder-head | Title row plus search |
.ck-folder-title | 20px semibold, truncates |
.ck-folder-actions | Head actions; .ck-icon-btn.is-add.is-primary is the round primary and opens the Create Folder modal |
.ck-folder-tree | The scroll area |
.ck-folder-group | A folder that contains rows. Carries aria-expanded |
.ck-folder-row | One row, all three levels |
.ck-folder-children | The nested block, indented 20px |
.ck-folder-chev | The disclosure turn. .is-empty keeps the lane, hides the glyph |
.ck-folder-glyph | Folder icon. Standard: one folder glyph at every level. Editor: open and shut crossfade so width never shifts. Folders only — never on a document row |
.ck-folder-tool.is-more | Horizontal kebab — the folder's actions. Standard variant, every folder row, first |
.ck-folder-tool.is-newfolder | New Folder inside this folder. Standard variant, every folder row, second |
.ck-folder-name | The label; truncates |
.ck-folder-tool | A row control, shown on hover or focus — .is-more, .is-newfolder (standard); .is-settings (editor) |
.ck-folder-doc | A document row: .ck-check then name. No glyph, no chevron lane |
[data-ck-menu] | Any button that opens a .ck-menu beside itself, by id |
[data-ck-folder-sort] | On the menu: the tree it sorts |
[data-ck-folder-new] | On the head's plus: the Create Folder dialog it opens, by selector |
[data-ck-folder-create-submit] | The dialog's Create Folder button |
.ck-folder-empty | Shown when a search matches nothing |
Sizes
| Part | Value |
|---|---|
| Panel width | 300px |
| Row padding | --space-sm |
| Row gap | 7px |
| Indent per level | --icon-size + row gap = 23px |
| Row control | 24px target, --icon-size glyph |
| Count | 20px (.ck-counter) |
| Top-level folder weight | semibold |
| Nested weight | normal; semibold when selected |
Tokens
| Token | Light | Dark | High contrast | What it is for |
|---|---|---|---|---|
--card | #ffffff | #233a3e | #000000 | the panel's surface |
--card-foreground | #05262e | #ffffff | #ffffff | text on it |
--panel-stroke | #c3ccd0 | #4f666a | #99a7ab | the panel's edge |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | a row under the pointer |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | the selected row's fill |
--selected-fg | var(--primary) | var(--primary) | var(--primary) | its text |
--foreground | anything a user must act on belongs
there for hierarchy, not here. */
--muted-foreground: #5a7278 | #ffffff | #ffffff | a row's label at rest |
--muted-foreground | — | #bcd7dd | #e0e0e0 | chevron, folder glyph, row controls |
--input | --input
is also the kit's disabled text and disabled stroke in ~40 places, and a
field edge is a quieter job. A subtle #d0d3d3 at rest (1.51:1 on white —
below the 1.4.11 3:1 line by the design owner's decision | #5f7073 | #999999 | a disabled row's label |
--blue-bg | #e3f0fe | #304b55 | #001a30 | the editor's count fill |
--blue-soft-foreground | #245fe8 | #92c4fe | #92c4fe | its numeral |
--blue-border | #b7d6fd | #3f5c6e | #92c4fe | its edge |
--amber-bg | #fef3c7 | #3d4a3b | #2a2000 | a listing's count fill |
--amber-soft-foreground | #b45309 | #fbbf24 | #fbbf24 | its numeral |
--amber-border | #fde68a | #595b38 | #fbbf24 | its edge |
--focus-ring | 0 0 0 3px #c6d4d7 | 0 0 0 3px #49585c | 0 0 0 3px #1a363c | keyboard focus on a row or control |
States
| State | What Changes |
|---|---|
| Rest | Transparent row, --muted-foreground glyphs |
| Hover | --hover-bg; row controls animate in |
| Focus | --focus-ring; controls reveal via :focus-within |
| Selected | --selected-bg / --selected-fg, semibold; the kebab and New Folder stay shown on the selected row |
| Open | Chevron at 90°, open folder glyph, children shown |
| Selected (editor) | Open folder glyph, even on a leaf. Highlight starts at that glyph. Standard keeps the one folder glyph |
| Disabled | --input label, no pointer events |
| Refreshing | .is-spinning on the refresh tool for 900ms |
| Filtered out | hidden |
| No matches | .ck-folder-empty shown |
Accessibility
A row is a real <button>, so Enter and Space work with no extra code. Up and down arrows walk the visible rows. aria-expanded on the group carries disclosure state, aria-current="page" carries selection, and every icon-only control has both an aria-label and a tooltip. The panel gets
aria-hidden="true" while collapsed so a screen reader does not read a tree nobody can see.
Events
| Event | Detail |
|---|---|
ck:folder-select | {name, level} |
ck:folder-toggle | {name, open} |
ck:folder-check | {name, checked, selected} — selected is every checked document |
ck:folder-sort | {direction} — asc or desc |
ck:folder-new | none — the head's plus was pressed; the modal is opening |
ck:folder-create | {kind: 'folder', name} from the modal, or {kind: 'folder', parent} from a row's New Folder |
ck:folder-more | {folder, button} — a row's kebab; the page opens its menu against button |
Don'ts
- Don't add
tree-*orfolder-*rules. Everything isck-prefixed now, and
the unprefixed names are what caused the trouble described in Notes.
- Don't hide the count on hover. It is information; the controls move, not it.
- Don't put a file icon or a chevron lane on a document row.
- Don't bring back a workspace level, a stacked-folders glyph or a Create Workspace option.
- Don't put a menu between the head's plus and the Create Folder modal.
- Don't reorder the row's trailing parts — kebab, New Folder, then the count.
- Don't offer creation in
.is-document. - Don't set the indent to a round number. Derive it from the icon and the gap.
- Don't omit the chevron on a leaf row — use
.is-empty. - Don't put the collapse toggle inside the panel.
- Don't reach for
!important. The old version needed nine of them; none of
them were the fix.
Icons
All taken verbatim from the references' own sprite, so the kit and the product draw the same glyph: folder, folder-open, chevron-right, sliders (settings), refresh-cw, arrow-up-down (sort), arrow-down-a-z and
arrow-down-z-a (the two sort options), plus, search, x. Two more are canonical Lucide, because the references' sprite does not carry them:
ellipsis (a row's kebab) and folder-plus (a row's New Folder).
Notes
This replaces a version spread across seven disconnected runs of
clipper-kit.css?v=8da91cd1 and built on unprefixed tree-* and folder-* classes that the product pages also owned. The kit and the pages were fighting at equal specificity, which is why it carried nine !important declarations and a set of doubled class selectors, and almost certainly why it looked wrong. The rename to ck-folder-* removes the collision, and with it every !important and every doubled selector.
Relation Tree
kitrelation-tree.mdA canvas of folders joined by arrows — which things feed which. Nodes are drawn as folders rather than icons on a plate, because the tree is a picture of folders and a plate around each one turns it back into a list. .is-source marks the folder the tree is drawn from — one folder differs, which is what makes it findable at a glance. An organism assembled from atoms: the zoom control is .ck-action-bar holding the kit's .ck-slider and an actual-size button — dragging is continuous, because the 25% step belongs to the button, not to the value. Both trees are live. Click a node and the others dim. The zoom bar is pinned to the panel, not to the canvas and not to the plot — anchored to the canvas it would scroll away with the content, anchored to the plot it would scale with the zoom it controls. It holds its corner and its size through every view change. Zoom with it, or hold Ctrl/⌘ and use the wheel; a bare wheel stays a scroll. Past 100% the canvas scrolls rather than clipping. Range 50–300%, continuous on the slider.
.size-smForm
newform.mdThe container the field atoms were always meant to sit in. Three variants, and the only thing that changes between them is the column count — one, two or three. The row rhythm, the label treatment, where the buttons go and what happens when a field fails are identical across all three, which is the whole reason the component exists. All three below are live: press the primary action on an empty form and watch where the errors come from.
- One form, one primary action. The action row holds exactly one
.ck-btn.primary. A second one asks the reader to rank two things the design already said were equal. - Error messages ship hidden and stay hidden until the logic fails. The text is in the markup from the start at
display:none; what reveals it is the control’s ownaria-invalid="true". Nothing is judged on load, and nothing is judged on the first keystroke — a form that opens red has told the reader off for doing nothing. - The error clears the instant the value becomes valid. An error that outlives its cause is worse than no error: the reader fixes the field and the form still calls them wrong.
- Column count follows the content, not the window. Three columns is for short values only. Anything that holds a sentence takes
.span-all. - Columns fold, they never squeeze. Three becomes two under 900px and everything becomes one under 620px. A 90px input is not a narrow field, it is a broken one.
- Rows align at the top, never stretch.
align-items:starton the grid — otherwise the one field showing an error makes every control beside it taller and the whole row loses its baseline. - Required is marked on the label, optional is marked in words.
data-requiredon.ck-field-label. Do not mark both; pick whichever is rarer in that form and mark only that. - The hint and the error never show together. Two lines of small print under one control, one grey and one red, is noise at the moment the reader most needs a single instruction.
- A section is a
<fieldset>with a real<legend>. The grouping has to survive with stylesheets off, and a screen reader announces the legend with every field inside it. - Every control in the form comes off the same size step. Mixing a 28px select with a 36px input in one row is the most common form defect in the product.
Every token this component resolves
| Token | Value | Where it lands |
|---|---|---|
--card | #ffffff | the panel surface when the form is carded |
--card-foreground | #05262e | every word on that surface |
--panel-border | #ebeff0 | the panel stroke |
--foreground | #05262e | the form title and every field label |
--muted-foreground | #5a7278 | the subtitle, the section legend and the hint line |
--border | #f5f5f5 | the rule above the action row |
--input | #8b9292 | the resting stroke on every control in the form |
--background | #ffffff | the field surface |
--ring | #1c5260 | the stroke of the focused control |
--primary | #013c4b | the one primary CTA |
--primary-foreground | #ffffff | its label |
--destructive | #e50600 | a field's error line, once that field has actually failed |
--destructive-bg | #ffeaea | the surface of the form-level summary |
--destructive-border | #d90500 | its stroke |
--destructive-soft-foreground | #d90500 | its text — the soft foreground, never the solid |
Values are the light theme. Each one is defined in all three themes; the component names tokens only, so dark and high contrast follow without a second rule.
Variant 1 — one column
A narrow panel, a drawer, or any form under about 480px. Every field is full width and the eye makes one pass down the page.
Variant 2 — two column
The default. Pairs that belong together sit on one line; the description takes .span-all because a sentence in a half-width box wraps four times.
Variant 3 — three column
Short values only — ids, dates, single-word statuses. The action row is .is-split so the destructive action cannot be hit on the way to Save.
Anatomy
| Part | Class | Notes |
|---|---|---|
| form | .ck-form | column flow, --space-lg between blocks |
| carded form | .ck-form.is-carded | adds --card, 16px radius, 1px --panel-border |
| head | .ck-form-head | .ck-form-title + .ck-form-sub |
| summary | .ck-form-error | hidden until .ck-form.is-invalid |
| section | .ck-form-section | a <fieldset>; .ck-form-legend is its <legend> |
| grid | .ck-form-grid | .cols-1 / .cols-2 / .cols-3 — the three variants |
| wide field | .span-2 / .span-all | on a .ck-field inside the grid |
| action row | .ck-form-foot | .is-split pushes a destructive action away from Save |
When an error is true
| State | What Is True | What the reader sees |
|---|---|---|
| At rest | nothing has been submitted | no red anywhere; every .ck-field-error is display:none |
| Leaving a filled field | focusout and the field has a value | that one field is judged, the rest are untouched |
| Leaving an empty field | focusout, no value, never submitted | nothing — tabbing through must not light the form up |
| Submit fails | checkValidity() false | summary appears, each bad field turns red, the first takes focus |
| Recovering | the value becomes valid | that field’s error goes immediately |
| Submit passes | every control valid | no red, the form submits |
Wiring
data-ck-form on the <form> is the whole setup. Validity comes off the native constraint attributes — required, type, minlength, pattern — so there is no second source of truth about what valid means. Override any message with data-error-required, data-error-type, data-error-pattern on the control.
ckForm.validate(form), ckForm.clear(form), ckForm.setError(control, message) and ckForm.clearError(control) are on window for anything the constraints cannot express — a server rejection, a cross-field rule.
Event Timeline
newtimeline.mdA vertical rail with one colour-coded dot per event, each carrying an actor, a change and a timestamp. The audit trail is built on it. Nothing in the kit expressed chronology before — a list of rows says what happened, not the order or the gaps between.
The Audit Trail
- Created
- Linked to PO-4417Unlinked→PO-4417
- Quantity Amended40→52
- Unlinked
One dot per event, colour-coded by outcome. The rail stops at the last dot.
Rules
- It is a real
<ol>. The Order is the content — with stylesheets off, or to a screen reader, the sequence has to survive. A stack of divs loses it. - The rail is drawn by the item, not the list. A border on the container overshoots past the final dot; a pseudo-element per row ends exactly where the last event does.
- The dot carries a
--backgroundring. It sits on the rail, so without the ring it reads as a bead threaded on a line rather than a marker breaking it. - Colour follows the semantic map, not the event name. Success, error, warning, info, neutral — the same five every other component reads (§ 14.3 check 46).
- Actor and timestamp are one line. They answer one question; two lines of small print would outweigh the event they describe.
- Timestamps are
tabular-nums. A column of times that does not align reads as a list of strings rather than a chronology. - A from/to pair puts the weight on the new value. The old one stays
--muted-foreground; only the destination takes--foreground.
Tokens
| Token | Value | Where it lands |
|---|---|---|
--border | #f5f5f5 | the rail |
--muted-foreground | #5a7278 | a neutral dot, the actor and the timestamp |
--background | #ffffff | the ring that breaks the rail behind a dot |
--success | #00875a | a completed event |
--destructive | #e50600 | a reversal |
--warning | #b95000 | an amendment |
--info | #0b6bcb | an informational event |
--foreground | #05262e | the event title and the new value in a from/to pair |
Card — dock, tour and preview
newsurfaces.mdThree card variants that reconciliation needed. Each is a modifier on .ck-card rather than a new object, because each one is still a card: a bounded surface holding related content. What changes is where it sits and what it holds. The dock is live — type in it and press Enter.
Comments dock — live, type and press Enter
Shown in flow here so the page can hold it; in the product it is position:fixed at the bottom-right.
Guided Tour
The spotlight is .ck-tour-mask — a full-page mask with the target cut out of it.
Preview Card
The header bleeds to the card edge, which is why the variant moves the padding off .ck-card and onto the body.
Rules
- The dock is a card, not a drawer, dialog or menu. It does not dim the page, does not trap focus and does not close on an outside click — the reader keeps working with it open. That is a card that floats.
- It takes
--popover, not--card. It floats, and § 4 binds the surface to the elevation. Invisible in light, obvious in dark. - Enter sends; Shift+Enter is a newline. A composer where Enter breaks the line makes the reader hunt for a button on every message.
- The thread sticks to the bottom only if the reader is already there. Forcing a scroll on arrival yanks them away from what they were reading.
- Anything a person typed is written with
textContent. NeverinnerHTML. - The tour mask is the one sanctioned uncomposited alpha. The cut-out shows the live page, not a knowable surface, so it cannot be pre-composited — the colour is still a token and the alpha still rides
--scrim-opacity(§ 2). - The preview card moves padding to the body. A header that bleeds to the edge cannot sit in a padded container; overriding the padding per consumer is how the two drifted apart before.
- A document type is not a status. The header tint comes from the palette tones, not from the five semantic ones.
Tokens
| Token | Value | Where it lands |
|---|---|---|
--popover | #ffffff | the dock and the tour card surface |
--popover-foreground | #05262e | every word on them |
--border | #f5f5f5 | the head and composer rules |
--hover-bg | #e7f9fe | the reader’s own message bubble |
--muted | #f1f5f6 | the preview header at rest |
--muted-foreground | #5a7278 | timestamps, the step label |
--scrim | #08272e | the tour mask |
--primary | #013c4b | the current step dot and the primary action |
--input | #8b9292 | the remaining step dots |
--info-bg | #e7f1fd | an invoice preview header |
Menu list, split button and split dialog
newmenu.md · button.md · dialog.mdThree variants on components that already existed. None of them needed a new component: a split button is two buttons, a value list is a menu nothing is chosen from, and a two-pane body is a dialog body with a rail in it.
Menu — the value list, .ck-menu.is-list
Nothing here is chooseable — no hover fill, no pointer. Reveal it only when the chip is genuinely cut.
Split Button — .ck-btn-group
Two real .ck-btn sharing an edge — every variant, size and state the button already has comes with it.
Dialog — the two-pane body, .ck-dialog-body.is-split
A 260px rail that does not scroll, beside a pane that does — the rail matches the folder navigator’s own width.
Rules
- A split button is a group, never a component. Two real
.ck-btnsharing an edge, so every variant, size and state comes free and nothing is re-declared. - The seam is one border, not two. The trailing half drops its leading border, so the pair reads as one control divided rather than two that happen to touch.
- A focused half lifts above its neighbour. Otherwise the ring is clipped by the adjacent border on one side.
- The value list is a menu that cannot be chosen from. No hover fill, no pointer, no row focus ring — the rows are values being read, not actions being picked.
- Reveal it only when the chip is genuinely truncated. A list repeating what is already legible is noise (§ 14.3 check 33).
- The split dialog body owns no padding. Each pane pads itself, or the divider cannot run the full height.
- The rail does not scroll with the pane. A rail that scrolls away is a heading that left, and the reader loses where they are.
- Both fold to one column under 620px. A 260px rail beside a pane on a phone leaves neither usable.
Tokens
| Token | Value | Where it lands |
|---|---|---|
--popover | #ffffff | the value list panel |
--popover-foreground | #05262e | its rows |
--panel-border | #ebeff0 | its stroke |
--primary | #013c4b | the split button’s filled half |
--primary-foreground | #ffffff | its label, and the seam between two filled halves |
--muted | #f1f5f6 | the dialog rail |
--muted-foreground | #5a7278 | the rail’s text |
--border | #f5f5f5 | the rail divider and the separators between fulfilment states |
--destructive-soft-foreground | #d90500 | a Remaining count |
--success-soft-foreground | #00875a | a Fulfilled count |
Table
kittable.mdSizes are row density, which is what a table's size actually means. The --muted header band with --foreground text is a declared pairing exemption: 15.87:1 where the nominal partner would be 3.16:1.
Search Over The Table — highlights as you type
Type in the search above the table: the keyword is marked in every cell that holds it, on every keystroke. This one also filters (data-ck-search-filter); without it, the rows stay and only the matches are marked.| Document | Vendor | Amount | Status |
|---|---|---|---|
| INV-2026-0412 | Orchard Provisions | 4,120.00 | Matched |
| INV-2026-0413 | Northwind Trading | 880.50 | Partial |
| INV-2026-0414 | Orchard Lane Dairy | 1,904.00 | Matched |
| PO-30-58440 | Bao Sheng Trading | 12,480.00 | Failed |
Child Rows — tr.is-child
| Document | Vendor | Amount |
|---|---|---|
| INV-20871 | Northwind Trading | 18,400.00 |
| PO-876567Royal Apples | Jurong → Tuas | 12,000.00 |
| PO-876567Club Apples | Jurong | 4,000.00 |
| PO-876568Pineapple | — | 2,400.00 |
| INV-20872 | Cormorant Logistics | 6,120.00 |
| PO-876570Storage | Tuas | 6,120.00 |
Press a chevron to close a group. The children sit on a --canvas band so the group reads as one block whatever the column widths are, each marked with a turn-down glyph and the reference it came from — a child with no reference is just an unexplained extra row. State lives on the parent’s aria-expanded, which is also what rotates the chevron, so the eye and a screen reader cannot disagree.
| Invoice | Supplier | Status | Amount |
|---|---|---|---|
| 220700734 | Bao Sheng Trading | Reconciled | 12,480.00 |
| 220700735 | Rong Fong Produce | Partial | 3,910.50 |
| 220700736 | Ocean Blue Seafood | Failed | 870.00 |
| size-sm | Amount |
|---|---|
| Dense row | 1,200.00 |
| Invoice | Supplier | PO | DO | Status | Due | Currency | Amount |
|---|---|---|---|---|---|---|---|
Matrix Variant — .ck-table.is-matrix
Row labels down the left, entities across the top, a mark at each crossing. It differs from a data table in one structural way and the rest follows: a matrix grows past its container because a column per entity has no upper bound, so it is max-content in a scroller with both the header row and the label column pinned. border-collapse goes to separate — a collapsed table drops a sticky cell's borders as it scrolls out.
| Permission | Owner | Admin | Viewer |
|---|---|---|---|
| Billing | |||
| View billing | |||
| Edit billing | |||
Sortable columns — three states, and the label names what happens NEXT
hover a header. The glyph is visible whether a column is sorted or not, because a column that can be sorted has to say so whether the cursor is there or not. The cycle is unsorted → ascending → descending → unsorted, and the tooltip offers Sort A to Z, then Sort Z to A, then Clear Sorting — never a description of the state you can already see. One column sorts at a time.It is always the same chevron pair, and the state is which half is lit. Unsorted, both halves sit at--muted-foreground: neither direction is in force and both are one click away. Ascending lights the up half in --primary and fades the down half to --input; descending does the reverse. So the lit half says which direction you are in and the faded half says which one you can go to. The fade is a token change, never opacity — a half-transparent stroke over the --muted header band is a different colour, not a dimmer one.| InvoiceSort a to Z | SupplierSort a to Z | Status | AmountSort a to Z |
|---|---|---|---|
| 220700734 | Bao Sheng Trading | Reconciled | 12,480.00 |
| 220700735 | Rong Fong Produce | Partial | 3,910.50 |
| SupplierSort a to Z |
|---|
| Bao Sheng |
| SupplierSort a to Z |
|---|
| Bao Sheng |
| SupplierSort a to Z |
|---|
| Ocean Blue |
Errored row, a dotted separator, and a row delete
the separator is a dotted--panel-border rule. An errored row sits on the soft destructive triplet, and hovering it deepens the RULE and drops a rail on the leading edge — not the fill, because at 4.58:1 a soft tint has no headroom left to darken without failing AA. The delete only appears on row hover or focus, and its cell holds its width either way so the row never reflows under the cursor.| Invoice | Supplier | Amount | |
|---|---|---|---|
| 220700734 | Bao Sheng Trading | 12,480.00 | Delete |
| 220700736 | Ocean Blue Seafood | 870.00 | Delete |
| 220700737 | Golden Harvest Ltd | 2,140.00 | Delete |
Data Types — .ck-table.is-data
the same table carrying every kind of value the product shows, one per column. Nothing here restyles a component: a badge in a cell is a badge, a dropdown is a dropdown, a switch is a switch. The cell classes handle only what a CELL owns — how the value aligns, and what it does when it is longer than its column. It is wide on purpose, so it sits in a.ck-table-panel and scrolls with its header pinned.| InvoiceSort a to Z | OwnerSort a to Z | Badge + Counter | Badge | Count | Dropdown | Editable | QtySort a to Z | Amount (SGD)Sort a to Z | ReceivedSort a to Z | Progress | Rating | Note (wraps) | Reference (truncates) | Auto | Actions | CTA | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 220700734 | WLWei Lin | wei.lin@baosheng.sg | Reconciled12 | Reconciled | 12 | 480 | 12,480.00 | 100% | All three pages matched on the first pass; no manual intervention needed. | PO-2024-00266-REV-C-FINALPO-2024-00266-REV-C-FINAL | |||||||||
| 220700735 | PRPriya Raman | priya@rongfong.com | Partial3 | Partial | 3 | 118 | 3,910.50 | 62% | Two line items are short-shipped. The supplier has been asked to confirm the balance. | PO-2024-00271-AMENDED-2PO-2024-00271-AMENDED-2 | |||||||||
| 220700736 | SOSam Okafor | sam.okafor@oceanblue.co | Failed1 | Failed | 1 | 24 | 870.00 | 18% | The scan is unreadable from page two onward, so nothing below the header could be matched. | PO-2024-00288-RESCAN-PENDINGPO-2024-00288-RESCAN-PENDING |
Table
.ck-table
What It Is
A data grid for reading and selecting rows.
Basic Information
Anatomy
<table class="ck-table size-sm is-sticky">
<thead><tr><th>Document</th><th>Vendor</th><th class="num">Total</th></tr></thead>
<tbody>
<tr aria-selected="true"><td>PO-2024-00266</td><td>Orchard</td><td class="num">1,240.00</td></tr>
</tbody>
</table>Sizes — density
Row padding, not a fixed box. A table's size is how many rows fit on screen.
| Class | Header Pad | Cell Pad | Type | Row | Reach for it when |
|---|---|---|---|---|---|
.size-sm | 7px 10px | 7px 10px | 12px | 32px | scanning many rows — a reconciliation list, an audit log. The common case for data-heavy screens |
| (none) | 10px 14px | 11px 14px | 12px | 40px | default — most tables |
.size-lg | 13px 16px | 15px 16px | 12px | 48px | few rows, each substantial — a summary of four or five items, or rows with two lines |
Modifiers
| Class | Effect |
|---|---|
.is-sticky | pins th at --z-sticky for a table scrolling in a fixed-height container |
.num on a cell | right-aligns and uses font-variant-numeric: tabular-nums |
aria-selected="true" / .is-selected on tr | --selected-bg + --selected-fg |
.is-loading | skeleton rows at the same row height |
.ck-table-empty | the no-rows message |
Sortable Columns
Three states, cycled in one order: unsorted → ascending → descending → unsorted.
aria-sort carries the state, so the CSS paints from it and a screen reader is told the same thing the glyph shows. Only one column sorts at a time — activating a header clears every sibling back to none.
It is always the same glyph — one chevron pair, an up chevron above a down chevron — and the state is WHICH HALF IS LIT.
aria-sort | Up Half | Down Half | Reads as | Tooltip and aria-label |
|---|---|---|---|---|
none | --muted-foreground | --muted-foreground | neither direction is in force; both are one click away | Sort A to Z |
ascending | --primary | --input | sorted up; down is available | Sort Z to A |
descending | --input | --primary | sorted down; up is available | Clear Sorting |
The lit half says which direction you are in; the faded half says which one you can go to. Swapping in a different glyph per state was wrong twice over: the mark moved under the cursor between states, and a single arrow can only say one of those two things.
The fade is a token change, never opacity. A half-transparent stroke over the --muted header band is a different colour, not a dimmer one, and it would shift again over a selected or errored row.
The sort glyph is always visible. An unsorted sortable column shows it at rest, both halves lit; a sorted column lights the half for its direction. Only the column’s delete control is revealed on hover.
The label names the NEXT action, not the current state. A tooltip that describes what you are already looking at tells you nothing the glyph has not already said. The same string goes on the tooltip and the aria-label, so pointer and keyboard users are offered the identical action.
ckTableSort() owns the state, the label and the announcement, and fires ck:sort with {column, direction}. It sorts nothing itself — sorting real rows means sorting real data, which belongs to whatever owns that data.
Row Separator, Errored Rows, and Row Actions
The separator is a 1px dotted --panel-border rule. It reads --panel-border rather than --border because a dotted rule shows less ink than a solid one of the same colour, and at --border (#f5f5f5, 1.09:1) it disappeared entirely.
An errored row sits on the soft destructive triplet. Hovering it deepens the stroke, not the fill — the rules go solid --destructive and a 2px rail lands on the leading edge. That is forced rather than chosen: § 8's derivation rule requires a hover fill to still clear 4.5:1 against its family foreground, and a soft tint has no headroom left (rest is already 4.58:1 in light, and reusing --destructive-border as a fill measures 3.57:1 in light and 1.00:1 in high contrast, where the text would vanish outright). Text contrast is untouched in all three themes, and the row still answers the cursor destructively instead of reverting to the neutral --hover-bg.
Row actions live in .ck-table-actions inside a td.ck-cell-actions. They appear on row hover and on :focus-within, and the cell holds its width either way so the row never reflows under the cursor.
Data Types — .ck-table.is-data
The same table carrying every kind of value the product shows, one per column.
Nothing here restyles a component. A badge in a cell is a badge, a dropdown is a dropdown, a switch is a switch. These classes handle only the two things a cell owns: how the value aligns, and what it does when it is longer than its column.
| Cell Class | Carries | Behaviour |
|---|---|---|
.ck-cell-flag | .ck-flag tone rail | 4px wide, flush to the table's left edge and the row's full height, square ends. Never the only carrier of status |
.ck-cell-chev | .ck-row-chev | expander; clockwise both ways, same keyframes as the accordion |
| — | .ck-cell-name + .ck-avatar | avatar then name, name truncates |
| — | .ck-pill + .ck-pill-count | badge with a counter |
| — | .ck-pill | badge without one |
.ck-cell-counter | .ck-counter | centred, shrink-to-fit |
| — | .ck-dd.size-sm | a dropdown in a cell |
.ck-cell-edit | .ck-input | reads as text until row hover or focus, then shows its stroke |
| — | .ck-cell-email | a real link: --link, underlined, thickens on hover |
.ck-cell-num | plain figures | right-aligned, tabular |
.ck-cell-amount | money | right-aligned, tabular, semibold. Currency goes in the header, never per row |
.ck-cell-date | a date | no wrap, tabular |
.ck-cell-progress | .ck-progress + a number | a bar alone is not a label |
| — | .ck-rating | five stars plus the score in text — counting glyphs is not reading a value |
.ck-cell-multi | long text | wraps, clamped to 3 lines |
.ck-cell-trunc | long text | truncates, full text in a tooltip on hover and focus |
.ck-cell-switch | .switch.size-sm | shrink-to-fit |
.ck-cell-bar | .ck-action-bar.size-sm | shrink-to-fit; every icon keeps its tooltip |
.ck-cell-cta | .ck-btn.size-sm | shrink-to-fit |
.ck-cell-multi and .ck-cell-trunc are not interchangeable. Wrap a value the user has to read in full; truncate one that is scanned in a column that has to stay narrow. Clamping at three lines stops one long note making a row twice the height of its neighbours.
The variant is wide by design. Put it in a .ck-table-panel > .ck-table-scroll with .is-sticky on the table, and it scrolls sideways with its header pinned and its pagination on the panel's bottom edge.
Tokens
| Token | Light | Dark | High Contrast | What It Is For |
|---|---|---|---|---|
--card | #ffffff | #233a3e | #000000 | the table surface |
--card-foreground | #05262e | #ffffff | #ffffff | cell text |
--muted | #f5f5f5 | #1b292d | #1a1a1a | the header band |
--foreground | #05262e | #ffffff | #ffffff | header text — a declared cross-pair at 15.87:1 |
--panel-border | #ebeff0 | #43575a | #99a7ab | the dotted row separator |
--hover-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | row hover |
--selected-bg | var(--nav-item-bg-active) | var(--nav-item-bg-active) | var(--nav-item-bg-active) | a selected row |
--selected-fg | var(--primary) | var(--primary) | var(--primary) | that row's text |
--primary | #013c4b | #e7f9fe | #66d9ef | a sorted column's glyph, and the errored row's hover rail |
--muted-foreground | #738f96 | #bcd7dd | #e0e0e0 | an unsorted column's glyph |
--destructive-bg | #ffeaea | #3d3e41 | #330000 | an errored row |
--destructive-soft-foreground | #d90500 | #fe9b98 | #ff4444 | that row's text |
--destructive-border | #fdc7c7 | #5a4345 | #ff4444 | that row's separator at rest |
--destructive | #e50600 | #fe9b98 | #ff4444 | that row's separator and rail on hover |
--input | #8b9292 | #5f7073 | #999999 | a flag with no tone, and the rating's empty stars |
--warning | #f2a618 | #f7b83d | #ffbb33 | the rating's filled stars |
--group | #e0f0ee | #18302f | #101c1c | the avatar plate |
--group-foreground | #05262e | #ffffff | #ffffff | avatar initials |
--link | #2562ee | #92c4fe | #88ccff | an email cell |
Special Rules
Rules That Matter
- Row height is set, not emergent.
--table-row-heightis 40px by default, 32px at.size-sm, 48px at.size-lg, andtdtakes its height from it..ck-table.is-loadinginherits the same property, so a loading row and a data row match by construction rather than by luck — the table can't jump when the real rows arrive. - Match the controls to the density. A
.size-smtable's row actions should be.ck-icon-btn.size-xs, and any inline control.size-sm. A 36px control in a 7px-padded row forces the row taller than its class implies. .numon every figure column. Proportional digits make a column of figures impossible to compare down the column, because the digit widths differ.aria-selectedon the<tr>is what announces selection. The fill is visual only.- For anything editable per cell, this is the wrong component — use a field grid.
- Wrap a paginated table in
.ck-table-panel. The rows scroll inside.ck-table-scrolland the pagination bar rides the panel's bottom edge, at any row count and while the data is still loading. - A sort label names what will happen, not what has happened. Sort A to Z → Sort Z to A → Clear Sorting. And one column sorts at a time.
- The sort glyph never hides. Only the column’s delete control is hover-revealed.
- A flag is never the only carrier of a row's status. A flagged row also says why in words, in a badge or a cell. It is flush to the wall and the row's full height — a floating capsule inset from the edge reads as a dot in a margin rather than as the row's own edge, and the dotted separator is dropped on that cell so the rule does not cut across the rail.
- A rating states its score in text. Five identical glyphs are not a value a screen reader can read, and counting them is not reading.
Accessibility
<th>in<thead>gives column headers for free. Addscope="col"when the table has both row and column headers.- A sticky header is
position: sticky, notfixed, so it doesn't trap focus. - Sortable columns need
aria-sorton thethand a real button inside it. The kit provides no sort control yet — that's a gap. - Row hover is a mouse affordance. Keyboard row navigation needs
aria-activedescendantor focusable rows, neither of which the CSS provides.
Don't
- Don't leave a scrolling table without
.is-sticky— it loses its column labels. - Don't render a header with nothing under it. Use
.ck-table-empty. - Don't use divs with
role="table"unless you have a specific reason.
Notes
.ck-table is --card — an elevated in-flow surface, not the page ground.
Cells use text-align: start / end rather than left / right, so they flip in RTL.
The row separator inherits --border's 1.09:1 in light. Tolerable here — it separates rows of labelled content rather than acting as an affordance — but a very long table will read as a wall of text. Row hover is what carries row-tracking, not the separator.