Conecte este design system ao seu agente de código no projeto. O Canon entrega as referências e instruções para o agente seguir nas próximas alterações de interface.
A few everyday elements, with your design decisions applied.
Active
Lumen website redesign
Marketing site · Due Sep 19
12 tasks, 4 in review. Sofia Almeida owns the design track, Daniel Costa the build.
Active users
2,420
12%
vs last month
Live component samples · Use the theme toggle to compare light and dark.
Direction
Canon
Canon keeps a white canvas, neutral grey surfaces and familiar product layouts. Its character lives in the details: Vera’s orchid pink for actions, DM Sans, flat controls and compact corners. IBM Plex Mono labels technical details; surface and text color mark navigation selection.
Large surfaces stay white or neutral grey. Pink is a small action or selection detail; do not tint the page background.
Controls are 40px with 14px medium text and 6px corners. Use a flat fill or a single outline, without a bevel or resting shadow.
Cards have 10px corners and a hairline. Hover and selection strengthen the outline. Reserve elevation for floating layers.
DM Sans for reading and headings; IBM Plex Mono for compact section labels, IDs and code. Product headings are 22–34px, with short, useful copy.
Navigation distinguishes hover from selection: quiet hover, a persistent line or solid segment for the current view. Do not turn every label into a pill.
Featured icons use a simple outlined frame with a small corner cut; avoid concentric rings. Use one only when it helps identify the block.
Focus uses a visible 3px ring in both themes. Dark surfaces use lighter pink actions with dark ink labels.
Never
Generic "SaaS dashboard" layouts: a grid of identical cards with soft shadows and an icon in a colored circle.
.cn-text-kicker11px / 1.5 · 500 · 0.08em · uppercaseA small, tracked mono label for orientation. Use ink, with a short rule to introduce major sections.
The signature eyebrow: mono, uppercase, letter-spaced, muted, with an optional 6×6 brand square before the text. It sits above a title and names the type of thing the title is.
Use: Above a page title, a card title or a section title ("Prospect", "Step 2 of 4", "Agent report"). Not for status (Badge), not for counts (Counter), not for form labels (Field). Never clickable: if it filters it is a Tag, if it acts it is a Button.
Above a titleKicker names the type, the title names the thing. space.2 between them.
Prospect
Nakamura Trading Co.
Osaka, Japan · Importer of frozen seafood
HTML
<div><div class="cn-kicker" data-tone="accent" data-size="sm"><span class="cn-kicker__mark"></span>Prospect</div><h2 class="cn-text-heading-lg" style="margin-top:var(--cn-space-2)">Nakamura Trading Co.</h2><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin-top:var(--cn-space-1)">Osaka, Japan · Importer of frozen seafood</p></div>
Matrix
tone ↓ / size →
sm
md
neutral
Prospect
Prospect
accent
Prospect
Prospect
success
Prospect
Prospect
warning
Prospect
Prospect
danger
Prospect
Prospect
Anatomy, props, states, rules
Anatomy
.cn-kicker<div> — Inline-flex row carrying the kicker type style and the muted color. <div> above block titles, <span> when inline.
.cn-kicker__mark<span> · optional — Optional 6×6px square before the text, radius 1.5px, filled with the tone color (brand accent by default). Empty, decorative.
Props
prop
values (default outlined)
meaning
mark
nonesquaredot
none = plain text (section subheading); square = 6px brand square before the text (Vero family signature); dot = round marker.
tone
neutralaccentsuccesswarningdanger
Color of the mark only; the text always stays fg-subtle. accent = the brand square (default: page and card titles); neutral = grey square for secondary sections; success/warning/danger = the section is about a state ("Verified", "Needs review", "Blocked").
size
smmd
sm = 11px, the canonical kicker above card titles and inside tables (equals the kicker type style, so markup without data-size renders sm); md = 12px for page-level eyebrows and marketing sections.
Rules
One kicker per title, directly above it, with space.2 between them. Never two kickers stacked.
The kicker names the TYPE of thing; the title names the thing: "Prospect / Nakamura Trading Co.", never "Nakamura / Prospect".
1–3 words. Write the source in sentence case ("Needs review"); the CSS uppercases it.
The text is always fg-subtle. Never color the text; tone colors only the mark.
Never a badge: no background, no border, no pill. Never clickable.
The mark goes only on the primary kicker of a surface (page title, card title). Inline and table uses go without the mark.
Do not use the kicker as a form label; Field labels are label-md in sentence case.
Column headers, chart legends and stat labels already speak in this voice through their own parts; do not nest a Kicker inside them.
Accessibility
The mark is an empty decorative span; screen readers skip it. Do not put text or an icon inside it.
The kicker is plain text, not a heading: no heading role, no aria-level. It is associated with the title by reading order.
Uppercasing is CSS, so assistive tech reads the source case ("Prospect", not letter by letter).
fg-subtle at 11px medium mono meets AA on canvas and surface; do not place kickers on tinted or dark backgrounds.
Typography
Prose .cn-proseedit
Container for long-form text (help articles, agent reports, release notes, legal): sets the reading measure and styles every HTML element inside it — headings, paragraphs, lists, quotes, code, tables — from the type scale, so markdown output looks right without classes.
Use: Wrap any block of rendered markdown or editorial HTML in it. Not for UI text (labels, descriptions, table cells), which uses the type utilities directly, and not for forms.
Article (md)
How agents verify a company
Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked verified.
Customs records show real import volume and product codes (HS 0306 for frozen shrimp).
The registry confirms the legal entity, its age and its directors.
The website confirms the product line and the contact channel.
“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”
Each check leaves a trace. Open a prospect and look for the verified_by field; it lists the sources and the date of the last check.
Source
What it proves
Refreshed
Customs records
Import volume, HS codes
Monthly
Business registry
Legal entity, directors
Quarterly
Company website
Product line, contacts
Weekly
When a check fails
The prospect stays in the results as unverified and the agent retries after 7 days. You can force a re-check from the prospect drawer.
<article class="cn-prose" data-size="md"><h2>How agents verify a company</h2><p>Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked <strong>verified</strong>.</p><ul><li>Customs records show real import volume and product codes (HS 0306 for frozen shrimp).</li><li>The registry confirms the legal entity, its age and its directors.</li><li>The website confirms the product line and the contact channel.</li></ul><blockquote><p>“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”</p></blockquote><p>Each check leaves a trace. Open a prospect and look for the <code>verified_by</code> field; it lists the sources and the date of the last check.</p><table><thead><tr><th>Source</th><th>What it proves</th><th>Refreshed</th></tr></thead><tbody><tr><td>Customs records</td><td>Import volume, HS codes</td><td>Monthly</td></tr><tr><td>Business registry</td><td>Legal entity, directors</td><td>Quarterly</td></tr><tr><td>Company website</td><td>Product line, contacts</td><td>Weekly</td></tr></tbody></table><h3>When a check fails</h3><p>The prospect stays in the results as <em>unverified</em> and the agent retries after 7 days. You can force a re-check from the prospect drawer.</p><pre><code>GET /v1/prospects/nk-2041/verification
→ { "verified": true, "sources": ["customs", "registry"], "checked_at": "2026-09-10" }</code></pre></article>
Small (inside a drawer)
Release notes · 11 Sep 2026
Agents now read replies in Japanese and German and draft answers in the buyer’s language.
Open a conversation with a reply in another language.
<article class="cn-prose" data-size="sm" style="max-width:360px"><h3>Release notes · 11 Sep 2026</h3><p>Agents now read replies in Japanese and German and draft answers in the buyer’s language.</p><ol><li>Open a conversation with a reply in another language.</li><li>Check the draft; the original is shown below it.</li><li>Approve, edit, or ask the agent to rewrite.</li></ol><p>Questions? See <a href="#">Working with drafts</a>.</p></article>
Large (marketing)
Find real buyers, not lists
Vera reads trade data the way an analyst does: who imported what, from where, how often. Then it writes to the people who can say yes — and waits for your approval before anything is sent.
Start with one product and one country. Most teams see the first verified reply within two weeks.
HTML
<article class="cn-prose" data-size="lg"><h2>Find real buyers, not lists</h2><p>Vera reads trade data the way an analyst does: who imported what, from where, how often. Then it writes to the people who can say yes — and waits for your approval before anything is sent.</p><hr><p>Start with one product and one country. Most teams see the first verified reply within two weeks.</p></article>
Matrix
size
sm
How agents verify a company
Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked verified.
Customs records show real import volume and product codes (HS 0306 for frozen shrimp).
The registry confirms the legal entity, its age and its directors.
The website confirms the product line and the contact channel.
“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”
Each check leaves a trace. Open a prospect and look for the verified_by field; it lists the sources and the date of the last check.
Source
What it proves
Refreshed
Customs records
Import volume, HS codes
Monthly
Business registry
Legal entity, directors
Quarterly
Company website
Product line, contacts
Weekly
When a check fails
The prospect stays in the results as unverified and the agent retries after 7 days. You can force a re-check from the prospect drawer.
Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked verified.
Customs records show real import volume and product codes (HS 0306 for frozen shrimp).
The registry confirms the legal entity, its age and its directors.
The website confirms the product line and the contact channel.
“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”
Each check leaves a trace. Open a prospect and look for the verified_by field; it lists the sources and the date of the last check.
Source
What it proves
Refreshed
Customs records
Import volume, HS codes
Monthly
Business registry
Legal entity, directors
Quarterly
Company website
Product line, contacts
Weekly
When a check fails
The prospect stays in the results as unverified and the agent retries after 7 days. You can force a re-check from the prospect drawer.
Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked verified.
Customs records show real import volume and product codes (HS 0306 for frozen shrimp).
The registry confirms the legal entity, its age and its directors.
The website confirms the product line and the contact channel.
“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”
Each check leaves a trace. Open a prospect and look for the verified_by field; it lists the sources and the date of the last check.
Source
What it proves
Refreshed
Customs records
Import volume, HS codes
Monthly
Business registry
Legal entity, directors
Quarterly
Company website
Product line, contacts
Weekly
When a check fails
The prospect stays in the results as unverified and the agent retries after 7 days. You can force a re-check from the prospect drawer.
.cn-prose<article> — The container. Max width 65ch, default text color, base font from size. Every element inside is styled by tag; the only class you may add inside is .cn-kicker above a heading.
Props
prop
values (default outlined)
meaning
size
smmdlg
Base text size. sm = body-sm with headings one step smaller (side panels, drawers, tooltips of an article); md = body-md, the app default (help center, reports); lg = body-lg for marketing and long reads.
Rules
Prose is for rendered documents; UI text never goes inside it. If an element needs a class, it does not belong in Prose (except .cn-kicker above a heading).
One h1 per document; start articles inside the app at h2 because the page already has the h1.
Keep the measure: never widen beyond 65ch. Wide layouts put Prose in one column and media or a table of contents in the other.
Links are ink-colored and underlined, never blue. External links get the external icon after the text.
Code inline for identifiers and values; pre for anything with more than one line. Never pre for a single command inside a sentence.
Tables inside Prose are simple (≤ 4 columns); anything sortable or wider is a Table component outside the Prose.
Images and video inside Prose get radius lg and a caption in the figcaption, not in the paragraph below.
Size follows the container: sm in drawers and popovers, md in app pages, lg only on marketing and long-read pages.
Accessibility
Use semantic elements (h2–h4 in order, ul/ol, blockquote, table with th) so the structure is navigable by headings and landmarks.
The root is an <article> (self-contained) or a <section> with aria-labelledby when it is part of a page.
Link text says where it goes ("Working with drafts"), never "click here"; underline is kept so color is not the only cue.
Code blocks scroll horizontally and are focusable (tabindex="0") when they overflow, so keyboard users can reach the content.
Typography
Link .cn-linkedit
An anchor in the brand link color, semibold, with an underline that appears on hover (the reference link-color style). Three voices: inline (inside running text), standalone ("View all →" at the end of a list, with a trailing icon) and muted (the gray link-gray style for footers, legal, meta).
Use: Use for navigation: to a page, a section, a document, an external site. Use Button (or Button variant="link"/"link-color") for actions that do not change the URL. Inline links only live inside sentences; anything that stands on its own line is standalone. Navigation lists (topbar, sidebar) are Tabs and SidebarNav, not rows of links.
Inline, in running textBrand-700 semibold; the brand-500 underline appears on hover.
<p class="cn-text-body-md" style="max-width:52ch">Interview notes are shared with the project by default. <a href="#" class="cn-link" data-variant="inline">Read how sharing works</a> before you change it, or <a href="#" class="cn-link" data-variant="inline">keep this study private</a>.</p>
Standalone with arrow14px semibold with a 20px brand-500 trailing icon (the reference link-color button, size md).
<a href="https://docs.lumen.app" target="_blank" rel="noopener noreferrer" class="cn-link" data-variant="standalone">Open the docs<span class="cn-sr-only"> (opens in a new tab)</span><svg class="cn-link__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3.5H3.5v9h9V9M9.5 3h3.5v3.5M13 3L7.5 8.5"/></svg></a>
Muted footer row with current pagethe reference link-gray: gray-600, gray-700 with a gray-400 underline on hover. aria-current="page" drops the underline and darkens the text.
.cn-link<a> — The anchor with a real href. Text names the destination ("Billing settings"), never "here" or "click".
.cn-link__icon<svg> · optional — Optional trailing 20px icon (16px on small text): arrow when it goes somewhere in the app, external when it opens a new tab. Brand-500 at rest, brand-600 on hover. Decorative (aria-hidden); the text carries the meaning.
Props
prop
values (default outlined)
meaning
variant
inlinestandalonemuted
inline = brand link color, semibold, inherits the surrounding size; underline appears on hover; use only inside running text (the reference link-color). standalone = the same as an inline-flex row with a trailing icon, 14px semibold; for "View all", "Open in…", "Learn more" that sit on their own line (the reference link-color button md). muted = gray text that darkens on hover with a gray underline; footers, legal links, timestamps that link (the reference link-gray).
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
focus
:focus-visible
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
current
[aria-current="page"], &[data-active]
The current item (navigation). (aria-current="page" or data-active)
Rules
Links are semibold in the brand link color; the underline is a hover affordance, so keep link text short and distinct from the sentence around it.
Inline links live only inside sentences and inherit the surrounding size; anything on its own line is standalone: "View all →", "Open the docs ↗", "Learn more →". One standalone link per card or list region, placed last.
Link text names the destination: "Billing settings", "the Q3 report". Never "here", "click here", "this page", "Learn more" without a subject.
A link changes the URL. If it opens a dialog, submits, toggles or deletes, it is a Button (variant link or link-color).
External links get target="_blank", rel="noopener noreferrer", the external icon and an sr-only "(opens in a new tab)".
Trailing icon only: arrow (in-app), external (new tab), or none. Never a leading icon and never two icons.
Do not underline navigation lists (Tabs, SidebarNav, Breadcrumb have their own styles). Muted is for footers, legal and meta text.
Accessibility
Always an <a> with a real href, so it works with middle-click, Enter, and the context menu; never a span with onClick.
Focus ring is the 4px box-shadow ring on :focus-visible (cloned across line breaks); never outline: none without replacement.
aria-current="page" on the link that points to the current page (footer, pagination-like lists).
New-tab links announce it via sr-only text; the icon is aria-hidden.
Links with the same text must go to the same place within a view; disambiguate with the subject ("Edit study", "Edit interview").
Typography
Code .cn-codeedit
Monospace code, inline or as a block. Inline is a quiet chip in running text (gray-50, 1px gray-200 ring, radius 4); block is a card surface (white, hairline ring, shadow-xs, radius 12) with an optional file-name header and a copy button.
Use: Inline for literals inside a sentence: commands, file names, attribute values, token names. Block for multi-line snippets, config files and terminal output. Keyboard shortcuts are Kbd, not Code; numbers in tables use numeric-md, not Code.
Inline12px mono on gray-50 with a gray-200 ring, radius 4.
Run canon build after editing design/tokens.json; the gallery at dist/preview.html reloads on its own.
HTML
<p class="cn-text-body-md" style="max-width:560px">Run <code class="cn-code" data-variant="inline" data-size="md">canon build</code> after editing <code class="cn-code" data-variant="inline" data-size="md">design/tokens.json</code>; the gallery at <code class="cn-code" data-variant="inline" data-size="md">dist/preview.html</code> reloads on its own.</p>
Block with file name and copyA card surface with a gray-50 header (12px semibold gray-500) and a 28px copy button.
Token {color.bg-action} uses the action color defined for the current theme.
HTML
<span class="cn-text-body-sm" style="color:var(--cn-color-fg-muted)">Token <code class="cn-code" data-variant="inline" data-size="sm">{color.bg-action}</code> uses the action color defined for the current theme.</span>
Matrix
variant ↓ / size →
sm
md
inline
npm install -g @lumen/canon
npm install -g @lumen/canon
block
npm install -g @lumen/canon
npm install -g @lumen/canon
Anatomy, props, states, rules
Anatomy
.cn-code<code> — Inline: a <code> span. Block: a <figure> wrapping the header and the pre.
.cn-code__header<div> · optional — Optional bar above the code: file path or language in the table-head voice (12px semibold gray-500) keeping its case, since file paths are case-sensitive. Copy button on the right, bottom hairline, gray-50 fill, 44px tall.
.cn-code__pre<pre> — The code in <pre><code>. 14px mono (12px on sm), 16px padding, scrolls horizontally, never wraps.
.cn-code__copy<button> · optional — 28px utility icon button (radius 6, 16px icon) in the header that copies the raw text. aria-label="Copy code".
Props
prop
values (default outlined)
meaning
variant
inlineblock
inline = chip inside text for a literal; block = full-width card surface with radius 12 for multi-line code, with optional header and copy button.
size
smmd
md = 12px inline and 14px block for docs and settings; sm = 11px inline and 12px block for tables, tooltips and popovers.
Rules
Inline code is for literals the reader might type or see verbatim: commands, file names, attribute values, token names. Never for emphasis.
Block code never wraps; it scrolls horizontally. Keep lines under 80 characters in docs and trim leading indentation.
The header shows the file path or the language in its original case (design/system.json, not DESIGN/SYSTEM.JSON). Omit it for terminal commands and one-liners.
One copy button per block, in the header, 28px utility. It copies the raw text (not the rendered HTML) and confirms with a short "Copied" tooltip.
Escape HTML inside <code> (< > &); never inject raw user content.
md in docs, settings and dialogs; sm inside tables, tooltips and popovers. Code does not scale with the surrounding heading.
Syntax highlighting, if any, uses fg-default, fg-muted, fg-accent and fg-info only. No rainbow palettes, no background per token.
Accessibility
Use the semantic elements: <code> inline and <pre><code> for blocks, so screen readers announce code.
The copy button has aria-label="Copy code" and a visible focus ring; announce "Copied" in a live region after the click.
When a block overflows horizontally, give the <pre> tabindex="0" so keyboard users can scroll it.
Never convey meaning by color alone in highlighted code; keep textual markers (+ / − in diffs).
Typography
Kbd .cn-kbdedit
A keyboard key drawn as the reference shortcut chip: 12px medium text on white with a 1px gray-300 ring, radius 4, 20px tall. Several in a row spell a shortcut (⌘ K).
Use: Show keyboard shortcuts in menus, tooltips, the command palette, input trailers and onboarding hints. Not for code (Code) and never as a button: a Kbd is not clickable.
Single key20×20 minimum, 12px medium gray-600, 1px gray-300 ring, radius 4.
⌘
HTML
<kbd class="cn-kbd" data-size="md">⌘</kbd>
ChordModifiers first, then the key, 4px between them.
Press ⌘K to open the command palette, / to search projects, or Esc to close any panel.
HTML
<p class="cn-text-body-md" style="max-width:560px">Press <kbd class="cn-kbd" data-size="md">⌘</kbd><kbd class="cn-kbd" data-size="md">K</kbd> to open the command palette, <kbd class="cn-kbd" data-size="md">/</kbd> to search projects, or <kbd class="cn-kbd" data-size="md">Esc</kbd> to close any panel.</p>
Small in a hint18px tall with 11px text for tooltips and table cells.
.cn-kbd<kbd> — One key: a symbol (⌘ ⇧ ⌥) or a short word (Esc, Enter, K). One Kbd per key; put them side by side for a chord.
Props
prop
values (default outlined)
meaning
size
smmd
md = 20px tall with 12px text, for menus, inputs, docs and the command palette (the reference chip); sm = 18px tall with 11px text inside tooltips and table cells.
Rules
One key per Kbd. A shortcut is a row of Kbds 4px apart, in the order pressed: modifiers first (⌃ ⌥ ⇧ ⌘), then the key.
Use platform symbols on macOS (⌘ ⌥ ⇧ ⌃ ⏎ ⌫ ⎋) and words on Windows/Linux (Ctrl, Alt, Shift, Enter). Detect the platform; never show both.
Letters are uppercase (K, not k); named keys are capitalized words (Esc, Tab, Space, Enter), never abbreviations like "Ret".
Kbd sits at the end of a menu item, tooltip, palette row or input, right-aligned. It never appears inside a button label.
Never use Kbd for pointer input (clicks, gestures) or for code; that is Code.
md by default; sm where the surrounding text is 12px (tooltips, table cells, dense menu rows).
Accessibility
Use the <kbd> element; screen readers read its text, so add aria-label on symbol keys ("Command", "Shift", "Escape").
A shortcut hint is supplementary: the same action must be reachable by pointer and through the menu.
Keep gray-600 on white; at 11–12px medium it meets AA. Do not lighten Kbd text to gray-500.
Actions
Button .cn-buttonedit
Triggers an action. Eleven intents share one shape (the system button): flat filled primary with a clear pressed state, ringed secondaries, transparent tertiaries and link styles, each in gray, brand and destructive voices. The intent is a data attribute, never a different component.
Use: Use for actions (save, send, open a dialog). Use Link for navigation inside text. One primary button per view region; put it last (right) in a row of actions. Destructive actions use the danger* variants and always confirm.
PrimaryFlat action fill with a contrasting label. One primary action per view region.HTML
.cn-button<button> — The interactive element. Always a <button type="button|submit"> or an <a role="button"> when it navigates. Add data-icon-only when it holds a single icon and a visually hidden label.
.cn-button__icon<svg> · optional — Optional leading or trailing icon, 20px (16px on xs). Decorative: aria-hidden. Matches the label on filled buttons; secondary icons use semantic foreground tokens.
.cn-button__label<span> — The action label. Verb first ("Save changes"), sentence case, no trailing period.
.cn-button__spinner<span> · optional — Shown only while data-loading; replaces the icon slot, the label becomes invisible but keeps its width.
primary = the main filled action; outline = a single neutral edge; secondary = a quiet filled alternative; secondary-color = an action-colored outline; ghost = a transparent toolbar action. link and link-color align with text. The danger variants apply the same hierarchy to destructive actions.
size
xssmmdlgxl
Control height and its matching padding, font and icon size.
icon
defaultonly
only = square icon-only button (padding equals the vertical padding); requires aria-label.
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
While pressed: the primary action deepens to bg-action-active. (native :active)
focus
:focus-visible
Keyboard focus. The token-defined focus ring (red for danger*) is added under the existing ring and shadow; never on mouse click. (native :focus-visible)
disabled
:disabled, &[aria-disabled="true"]
Not interactive. Whole button at 50% opacity, cursor not-allowed, no hover. (disabled attribute (or aria-disabled="true"))
loading
[data-loading]
Async action in flight. Shows the spinner, hides the label visually, keeps width. (data-loading attribute)
Rules
Exactly one primary button per view region (a dialog, a page header, a form). If two actions feel primary, one of them is not.
Labels are verbs in sentence case: "Save changes", "Send invite", "Delete". Never "OK", "Submit", "Click here".
Icons are optional and decorative. Leading icon = what the action is (plus, upload); trailing icon = where it goes (arrow, external). Icon-only buttons set data-icon-only plus aria-label (or use IconButton).
Never change the height by adding padding. Height comes only from size: 32 / 36 / 40 / 44 / 48px, padding 10 / 12 / 14 / 16 / 18px.
Link variants (link, link-color, danger-link) have no padding and no height: they align with text. Use them for a low-emphasis action in a row of controls, never inside running text (that is Link).
Loading: set data-loading and aria-busy="true"; keep the label in the DOM so width does not jump. The fill becomes the hover fill.
Do not put two filled (primary + danger) buttons side by side. Danger actions live behind a confirm dialog.
Full-width buttons only on mobile sheets and auth forms.
Accessibility
Use <button>. Only use <a> when the action navigates, and then it is a Link or a button-styled anchor with a real href.
Icon-only buttons need an accessible name: aria-label on the button (or a cn-sr-only label inside), plus data-icon-only for the square padding.
disabled removes it from the tab order; if the user needs to know why, keep it enabled and show the reason on click.
Focus ring is shown on :focus-visible only, as a 4px box-shadow ring; never outline: none without replacement.
Actions
IconButton .cn-icon-buttonedit
A square (or round) button holding exactly one icon: icon-only button and its utility button. Same intents and sizes as Button, width equals height, the icon sits in a muted gray until hover, and the accessible name lives in aria-label.
Use: Use for a frequent, unambiguous action next to content: close, more actions, copy, delete a row, toggle a panel. If the icon needs a caption to be understood, use a Button with a label instead. Toggles (pin, bookmark, show calendar) use aria-pressed. Always pair with a Tooltip that repeats the aria-label.
Ghost (toolbar default)Transparent, gray-400 icon; gray-50 fill and a darker icon on hover.HTML
.cn-icon-button<button> — The interactive element. <button type="button"> with a mandatory aria-label; <a> only when it navigates.
.cn-icon-button__icon<svg> — The single icon, 20px (16px on xs). Decorative: aria-hidden="true". No text inside the button.
.cn-icon-button__spinner<span> · optional — Shown only while data-loading; the icon becomes invisible, the button keeps its size.
Props
prop
values (default outlined)
meaning
variant
primarysecondaryoutlineghostdangerutility
ghost = default for toolbars, table rows and card headers: transparent, gray-400 icon, gray-50 fill on hover (Tertiary icon-only). utility = the same colors in a smaller 6px-radius box for close (×) buttons, table-row and file-list actions (ButtonUtility tertiary: 28px on xs, 32px on sm). outline = white with a gray-300 ring when it stands alone on the canvas (Secondary icon-only). secondary = gray-50 fill with a ring next to a filled action. primary = the one filled action (rare: a "+" that is the main action on a small screen). danger = destructive, always behind a confirm.
size
xssmmdlgxl
Control height and its matching padding, font and icon size.
shape
squarecircle
square = 8px radius (6px for utility), sits in toolbars and rows next to Buttons; circle = fully round, for avatars and floating actions. Do not mix shapes in one row.
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
While pressed. has no distinct pressed style: it keeps the hover colors. (native :active)
focus
:focus-visible
Keyboard focus. The 4px brand ring (red for danger) is added under the existing ring and shadow. (native :focus-visible)
disabled
:disabled, &[aria-disabled="true"]
Not interactive. Whole button at 50% opacity, cursor not-allowed, no hover. (disabled attribute (or aria-disabled="true"))
loading
[data-loading]
Async action in flight. Shows the spinner, hides the label visually, keeps width. (data-loading attribute)
pressed
[aria-pressed="true"]
Toggle is on. Gray-50 fill + default ink so the state is visible at rest, not only on hover. Use with ghost, utility or outline. (aria-pressed="true" (toggle buttons only))
Rules
aria-label is mandatory and names the action, not the icon: "Delete row", not "Trash".
Always pair with a Tooltip that shows the same text as aria-label; hover reveals what keyboard users already hear.
Ghost inside toolbars, table rows and card headers; utility for close buttons and file-list actions; outline when the button stands alone on the canvas; primary only when the icon action is the main action of the region.
No text inside an IconButton. The moment it needs a caption, it is a Button with a leading icon.
Toggles set aria-pressed and use ghost, utility or outline; the icon keeps its meaning in both states (a pin stays a pin, it does not become a cross).
Icon size comes from size; never scale the svg by hand or pad the button to change its footprint.
Touch targets: md (40px) or larger on mobile and in touch-first screens; xs/sm are for dense desktop rows only.
One shape per row. Square next to Buttons; circle for avatars and floating actions.
Destructive icon actions (danger, or a ghost trash icon) always confirm before acting.
Accessibility
Use <button type="button"> with aria-label (or aria-labelledby). An icon alone has no accessible name.
The svg is aria-hidden="true" and focusable="false"; the name comes from the button.
Toggle buttons expose aria-pressed="true|false" and keep the same aria-label in both states.
Focus ring on :focus-visible only, as a 4px box-shadow ring; never outline: none without a replacement.
While loading set aria-busy="true" and keep the aria-label; disabled removes it from the tab order.
Actions
ButtonGroup .cn-button-groupedit
A row (or column) of related Buttons. Attached groups fuse into one control by squaring the inner corners and overlapping the 1px rings (the reference button group); spaced groups just keep a consistent gap.
Use: Use attached for actions that belong to one object (Assign · Snooze · Archive), for split buttons (action + chevron menu) and for a toggle group where one option is on (aria-pressed on the selected child). Use spaced for a row of independent toolbar actions. For a rail-style single choice use SegmentedControl.
Attached (actions on one object)Outline buttons fuse into one control: inner corners squared, rings overlap by 1px, shadow-xs on the group.
Vertical attached with a disabled childChildren stretch to the widest label; text aligns to the start. A disabled child fades its content, not its ring.
.cn-button-group<div> — Inline-flex container with role="group" and an aria-label. Children are outline Buttons or IconButtons of the same size; attached groups carry the shadow-xs and the 8px outer radius.
Props
prop
values (default outlined)
meaning
variant
attachedspaced
attached = no gap, inner corners squared, rings overlap by 1px so the group reads as one control (the reference ButtonGroup); spaced = 12px gap, every button keeps its own radius and shadow.
orientation
horizontalvertical
horizontal = a row (toolbars, headers, split buttons); vertical = a stacked column (zoom controls, ordering, side rails). Vertical attached groups stretch children to the same width.
Rules
Group only actions that act on the same object or belong to the same tool. Cancel and Save are not a group; they are an action row.
All children share one size and the outline variant. The single exception is the split button: primary action + primary chevron.
Attached groups hold 2–5 children. Beyond that, use a toolbar of spaced groups or move the rest into a Menu.
Attached = one control (the corners say so). Spaced = several controls. Do not attach unrelated tools just to save space.
A toggle group marks exactly one child with aria-pressed="true" (a range, a view). For a rail with a lifted white item use SegmentedControl instead.
Vertical groups are rare: zoom controls, reorder handles, a rail of icon actions. Never a vertical group of text actions in a form.
Do not mix Button and IconButton sizes inside one group; the heights must match exactly for the rings to overlap cleanly.
Disabled children stay in the group (the layout must not jump); the reason shows in a Tooltip.
Accessibility
The root has role="group" and an aria-label naming what the actions act on ("Conversation actions").
Every child is a real <button>; IconButtons keep their aria-label. The group adds no keyboard behavior (Tab moves between children).
Toggle children expose aria-pressed="true|false"; a split button chevron exposes aria-haspopup="menu" and aria-expanded with its own aria-label.
Focus rings are raised above neighbours (z-index) so the ring is never clipped by the next button.
Actions
AppStoreButton .cn-app-store-buttonedit
The download badge for a mobile app: a 40px box with the store glyph on the left and two lines of text, a tiny kicker ("Download on the") over a bold store name. Dark by default, or outline on dark and busy backgrounds.
Use: Marketing pages, app footers and "get the app" banners, always as a pair (both stores) or a single badge when only one exists. Never inside the app shell, never as a generic download button (use Button with a leading icon).
Dark pairThe standard pair on a light page: inverse fill, thin grey border, 40px tall.
.cn-app-store-button<a> — The badge link (inline-grid: icon column + two text rows). href points to the store listing; aria-label repeats the two lines. Carries data-store and data-theme.
.cn-app-store-button__icon<svg> — 24px store glyph spanning both text rows. Monochrome, currentColor. aria-hidden.
.cn-app-store-button__kicker<span> — The small first line ("Download on the", "Get it on"), 11px medium, at 70% of the text color.
.cn-app-store-button__label<span> — The store name in 16px semibold with tight tracking.
Props
prop
values (default outlined)
meaning
store
applegoogle
apple = "Download on the App Store"; google = "Get it on Google Play". The store also picks the glyph and the copy in the markup.
theme
darkoutline
dark = inverse fill with light text and a thin grey border, the standard badge on light pages; outline = surface fill with a hairline and default ink, for dark or photographic backgrounds and for footers where a black block would be too heavy.
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
Rules
Always 40px tall, never scaled: the badge is a fixed-size mark, not a button that grows with the layout.
Show both stores side by side with a 12px gap, Apple first, in the same theme; show one badge only when the app really exists on one store.
Copy is fixed by the store: "Download on the / App Store" and "Get it on / Google Play". Do not translate the store names or invent kickers.
dark on light pages; outline on dark, photographic or tinted backgrounds and in footers. Never both themes on one page.
In production use the stores' official badge artwork inside the icon slot and follow their clear-space rules; the example glyphs are placeholders.
The badge links to the store listing (or a smart link) and opens in the same tab on mobile, a new tab on desktop.
Never place the badges inside the app shell or next to in-app actions; they belong to marketing surfaces and onboarding emails.
Accessibility
It is an <a href> with an aria-label that reads both lines ("Download on the App Store"); the glyph is aria-hidden.
The two text lines are real text, not an image, so they scale with the user's font size and remain translatable.
Focus ring on :focus-visible; on the dark theme it is the only hover-independent state, so keep it visible.
Kicker opacity is 70% of the text color, which keeps contrast ≥ 4.5:1 on both themes; do not lower it.
Actions
CloseButton .cn-close-buttonedit
The × that dismisses dialogs, drawers, alerts, toasts and chips. A square utility button with a grey icon and a subtle hover fill.
Use: Use only to dismiss the surface it sits in (top-right of dialogs/drawers/toasts, end of alerts/banners). It is never a "cancel" action in a footer; use a ghost Button there.
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
disabled
:disabled, &[aria-disabled="true"]
Not interactive. Reduced opacity, no hover, cursor not-allowed. (disabled attribute (or aria-disabled="true"))
Rules
Always aria-label="Close" (or "Dismiss notification"), never a visible label.
Position: top-right, aligned with the title baseline, with the same inset as the surface padding minus 8px so the icon aligns with the content edge.
sm inside cards, alerts, toasts and chips; md/lg only in dialogs and drawers.
Escape must trigger the same close action wherever a close button exists.
Accessibility
It is a real <button type="button"> with an accessible name.
Focus returns to the trigger that opened the surface after closing.
Actions
SocialButton .cn-social-buttonedit
A sign-in button for an identity provider: the Button box with a 24px logo slot and a "Sign in with …" label. Three themes: the provider's brand fill, a white gray button with a monochrome logo, or a white button with the colored logo.
Use: Auth screens only (sign in, sign up, connect an account). Stack the providers you support in a fixed order under the email form, all in the same theme and size. Never use it for sharing links or as a generic icon button.
Gray (default)White with a hairline and a monochrome logo; the quiet default under an email form.
.cn-social-button<button> — The button (or an <a> when the flow is a redirect). Full width in auth forms. Carries data-social, data-theme, data-size.
.cn-social-button__icon<svg> — 24px logo slot. Monochrome (currentColor) in brand and gray themes, the provider color in the color theme. aria-hidden.
.cn-social-button__label<span> — "Sign in with Google" / "Continue with Apple": verb + provider name, sentence case.
Props
prop
values (default outlined)
meaning
social
googleapplegithubfacebook
The identity provider. It selects the brand fill (brand theme) and the logo color (color theme); the logo itself comes from the markup.
theme
brandgraycolor
gray = white, hairline, monochrome logo: the default that sits quietly under an email form; brand = the provider's fill with a white logo, for a single prominent provider; color = white with the provider-colored logo, for a row of several providers on marketing-style auth pages.
size
mdlgxl
Button heights md / lg / xl (40 / 44 / 48px in the reference scale). Match the size of the email form's submit button.
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
disabled
:disabled, &[aria-disabled="true"]
Not interactive. Reduced opacity, no hover, cursor not-allowed. (disabled attribute (or aria-disabled="true"))
Rules
Label = verb + provider: "Sign in with Google" on sign-in screens, "Continue with Google" when the same button signs up. Never the provider name alone, never "Login".
One theme and one size per screen. gray under an email form; color when providers stand alone in a row; brand only for a single provider that is the main way in.
Order providers by usage in your audience and keep that order on every screen; put the email form above them, never below.
Full width (data-full) inside auth forms; intrinsic width only in a horizontal row of icon-heavy color buttons.
The logo slot is 24px; use the provider's official monochrome or color asset in production and respect its clear-space rules. The example glyphs are placeholders.
Never add a trailing arrow or a second icon; the logo is the only icon.
While the provider popup is open set aria-busy="true" and disable the other providers to avoid two flows at once.
Accessibility
It is a <button type="button"> (or an <a href> for redirect flows) with the visible label as its accessible name; the logo is aria-hidden.
Focus ring on :focus-visible only, stacked over the resting shadow so the ring is visible on filled brand buttons too.
Disabled providers stay visible and readable (opacity only); explain why when it matters ("Apple sign-in is not available in this region").
Contrast: brand fills use the 600 step with white text; gray and color themes use default ink on the surface.
Forms
SegmentedControl .cn-segmented-controledit
Single-choice group drawn as one rail: a gray-50 track with a hairline ring and 4px padding where the selected item lifts on a white surface with shadow-sm. Same heights (36 / 40px) and 14px semibold type as ButtonGroup, so the two sit side by side in a toolbar.
Use: Two to five mutually exclusive views or modes whose change is immediate (List / Board, Day / Week / Month, Monthly / Annual). For form data use Radio; for navigation between pages use Tabs; for actions on one object use ButtonGroup; for more than five options use Select.
Default40px rail: gray-50 with a hairline ring and 4px padding; the selected item is white with shadow-sm.
.cn-segmented-control__icon<svg> · optional — Optional leading icon (16px on sm, 20px on md) inside an item, gray-500, or the only content with aria-label on the item. Decorative.
Props
prop
values (default outlined)
meaning
size
smmd
md = 40px rail with 32px items (matches md controls and a md ButtonGroup); sm = 36px rail with 28px items for cards, tables and popovers.
variant
defaultfull
default = items at their content width, the rail hugs them; full = the rail fills its container and every item takes an equal share (mobile sheets, narrow cards).
States
state
selector
meaning
selected
[aria-checked="true"], &[data-selected]
On the __item (not the root): white surface, default ink, shadow-sm; the icon darkens. Exactly one per group. (aria-checked="true" on the item button (data-selected also accepted))
hover
:hover:not(:disabled)
On an unselected __item: text turns to the default ink and the icon darkens; no background change. (native :hover on the item)
focus
:focus-visible
On the __item: the 4px brand ring on the item itself, raised above its neighbours. (native :focus-visible on the item)
item-disabled
:disabled
On the __item: 50% opacity, cursor not-allowed; stays visible so the option is known to exist. (disabled on the item button)
disabled
[data-disabled]
Whole group at 50% opacity and not interactive. (data-disabled on the root)
Rules
Use for 2–5 mutually exclusive views or modes whose change is immediate. For form data use Radio; for page navigation use Tabs; for actions on one object use ButtonGroup.
Labels are one or two words in sentence case, or an icon with aria-label. Keep every item in the same voice: all nouns or all icons, never mixed.
One item is always selected; there is no empty state and no toggling off.
variant="full" only inside narrow containers (mobile sheets, cards ≤ 400px) so each item stretches equally; default keeps items at content width.
md (40px) matches md controls in a toolbar; sm (36px) sits inside tables, cards and popovers. Never place a SegmentedControl taller than the controls beside it.
Maximum five items. Beyond that the choice becomes a Select or Tabs.
Do not mix action buttons into the rail; put related actions in a ButtonGroup beside it, 12px away.
Accessibility
The root has role="radiogroup" with aria-label (or aria-labelledby); each item is a <button role="radio" aria-checked="true|false">.
Roving tabindex: only the selected item has tabindex="0"; Left/Right (and Up/Down) move selection and check the new item, Home/End jump.
Icon-only items require aria-label; the svg is aria-hidden.
Disabled item: disabled plus aria-disabled="true"; a disabled group sets data-disabled and aria-disabled on the root.
Selection change must not move focus or navigate. If it changes the URL, use Tabs instead.
Forms
Field .cn-fieldedit
Label, control and one line of hint or error text stacked 6px apart (the reference text-field rhythm). The label is 14px medium with an optional brand asterisk and help icon; the hint is 14px muted and turns red when the field is invalid. It owns the words around a control so every form reads the same way.
Use: Wrap every Input, Textarea, Select, Combobox, Slider and FileDropzone in a Field so it gets a visible label and a place for hint and error text. Do not wrap Checkbox, Radio or Switch (they carry their own label; group them with a radio-group or a fieldset). Use layout="horizontal" only on settings pages where many short fields stack under one another.
Vertical (default)Label 14px medium, 6px, the control, 6px, a 14px muted hint.
The legal name that appears on invoices.
HTML
<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:360px"><label class="cn-field__label" for="field-company">Company name</label><div class="cn-field__control"><div class="cn-input" data-variant="default" data-size="md"><input class="cn-input__field" id="field-company" type="text" placeholder="Lumen Studio" aria-describedby="field-company-hint"></div></div><p class="cn-field__helper" id="field-company-hint">The legal name that appears on invoices.</p></div>
Required with help iconThe brand asterisk sits 2px after the label; the 16px help icon opens a Tooltip.
.cn-field<div> — The wrapper: a 6px vertical stack by default; a label-column grid in horizontal layout. Carries data-invalid and data-disabled.
.cn-field__label<label> — Visible label with for="<control id>": 14px medium in the default ink, a flex row with 2px gaps for the asterisk and help icon. Sentence case, no trailing colon, 1–4 words ("Company name").
.cn-field__required<span> · optional — Optional brand asterisk right after the label text (2px away) marking a required field; turns red when invalid. aria-hidden — the control carries required.
.cn-field__tooltip<button> · optional — Optional 16px help icon after the label that opens a Tooltip on hover/focus (aria-label = the tooltip text). Gray-500, darker on hover.
.cn-field__optional<span> · optional — Muted "(optional)" marker inside the label, regular weight, for forms that mark optional fields instead of required ones.
.cn-field__control<div> — Slot holding exactly one control (an Input, Select, Textarea, Combobox…). No styling besides min-width: 0.
.cn-field__helper<p> · optional — The hint: one short line under the control, 14px (12px on sm) muted: format, example or limit ("Shown on invoices"). Hidden while the field is invalid.
.cn-field__error<p> · optional — Validation message in the same size as the hint, in the danger color (an optional 16px icon may lead it). Hidden until data-invalid is on the root; give it an id and reference it from the control with aria-describedby.
Props
prop
values (default outlined)
meaning
layout
verticalhorizontal
vertical = label above the control (forms, dialogs, onboarding); horizontal = label in a fixed 192px left column with the control on the right (settings pages with many short fields).
size
smmd
md = 14px label and 14px hint, pairs with md/lg controls; sm = 14px label and 12px hint, pairs with sm controls in dense panels and filters (the reference size="sm" hint).
States
state
selector
meaning
invalid
[data-invalid]
Shows the error in the danger color, hides the hint and turns the asterisk red. Set together with aria-invalid="true" on the control. (data-invalid on the root)
disabled
[data-disabled]
The control fades on its own (50% opacity); the label and hint stay readable and the label shows the not-allowed cursor. Set together with disabled on the control. (data-disabled on the root)
Rules
Every control gets a visible label. Placeholder text is an example value, never the label.
Labels are 1–4 words in sentence case with no colon. Name what goes in the field ("Work email"), not the action ("Enter your email").
Pick one convention per form: mark required fields with the brand asterisk, or mark optional ones with "(optional)". Never both.
Hint text is one sentence in present tense that answers "what format" or "why do you need it". If it needs two lines, the label or the flow is wrong.
Invalid: set data-invalid on the Field AND aria-invalid="true" on the control. The error replaces the hint and says how to fix it ("Enter a complete address"), not just "Invalid".
The help icon carries context that does not fit the hint (why, where it is used); it never replaces the hint or the error.
Use one layout per form. Horizontal settings fields have a 192px label column and stack vertically below 640px of available width.
Fields stack 20px apart; two related short fields (city + postal code) may share a row 16px apart.
Constrain short values (postal code, quantity, currency) with max-width on the Field, not on the control.
Accessibility
The <label for> references the control id, so clicking the label focuses the control and screen readers announce it.
The asterisk is aria-hidden; put required on the control (or aria-required) so the requirement is announced.
The help icon is a real <button> with aria-label equal to the tooltip text; the tooltip is referenced with aria-describedby.
When invalid, the control has aria-invalid="true" and aria-describedby pointing at the error id; when valid, aria-describedby points at the hint id.
The error text is static in the flow. Announce submit results with a separate live region, not by making every error aria-live.
Forms
Input .cn-inputedit
Single-line text field: a white wrapper with a 1px gray-300 inset ring and shadow-xs, radius 8, 36 / 40 / 44px tall. Optional leading icon, inline prefix or suffix, a trailing help icon (tooltip) and a keyboard-shortcut chip. The wrapper carries the ring; the native input is bare.
Use: Free text, numbers, email, search, URLs. Pair with Field for label + hint + error. For choices use Select or Combobox; for multi-line text use Textarea.
Default40px tall, 12px padding, gray-300 ring and shadow-xs, radius 8.
With help icon and shortcutThe help icon (16px, gray-500) sits 12px from the edge and opens a Tooltip; the ⌘K chip sits 8px from the edge and hides under 768px.
.cn-input<div> — Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within). Flex row, height from size.
.cn-input__icon<svg> · optional — Optional leading icon (search, mail): 16px on sm, 20px on md and lg, gray-500, inset by the horizontal padding; the text starts 8px after it (36 / 40 / 42px from the edge). Decorative.
.cn-input__affix<span> · optional — Optional inline prefix or suffix text ("https://", "USD", "kg") in the muted color, no fill and no rule: padded 12px on the outside (14px on lg) and 6–8px next to the text.
.cn-input__field<input> — The native <input>. No border, no outline, fills the wrapper; padding 12px (14px on lg) on each side.
.cn-input__tooltip<button> · optional — Optional trailing 16px help icon that opens a Tooltip on hover/focus (aria-label = the tooltip text). Gray-500, darker on hover. When the field is invalid the same slot shows the alert icon in the danger color.
.cn-input__shortcut<span> · optional — Optional keyboard-shortcut chip at the trailing edge ("⌘K"): 12px medium gray-500 on white, 1px gray-200 ring, radius 4, padding 1px 4px, 6 / 8 / 10px from the edge. aria-hidden; hidden under 768px.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
variant
defaultfilled
default = white with the gray-300 ring and shadow-xs (the reference input; forms, dialogs, settings); filled = gray-50 fill without ring or shadow until focus, for dense toolbars and table filters (no reference equivalent).
States
state
selector
meaning
hover
:hover:not([data-disabled])
Pointer over the field. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring. (native)
focus
:focus-within
The inset ring turns to the brand color and the 4px brand focus ring appears around the wrapper (never on the input itself). (native :focus-within on the wrapper)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring; on focus the ring stays red and the focus ring turns red. The help slot shows the alert icon in the danger color. Always accompanied by a Field error. (aria-invalid="true" on the input, or data-invalid on the wrapper)
disabled
[data-disabled], &:has(:disabled)
Whole control at 50% opacity, cursor not-allowed, same fill and ring. (disabled on the input)
readonly
:has([readonly])
Looks like default; text stays selectable and the focus ring still shows. (readonly on the input)
Rules
Always inside a Field with a visible label, except search inputs with a leading icon and a placeholder that says what is searched.
Placeholder is an example, not a label ("maya@lumen.app", not "Email").
Width comes from the layout (100% of the Field). Never set a fixed pixel width; use max-width on the Field for short values.
Sizes: md (40px) in forms and dialogs; sm (36px) inside toolbars, tables and filters; lg (44px) on marketing and auth screens where 16px text is wanted.
Never change the height by adding padding. Height comes only from size: 36 / 40 / 44px with 12 / 12 / 14px horizontal padding.
The help icon opens a Tooltip and never replaces the hint; the shortcut chip is decorative and only appears on fields that really have a global shortcut.
Error state must show a Field error under the input; the red ring and icon alone are not enough.
On mobile the font-size must be ≥ 16px to prevent Safari zoom: use size lg or the pattern in DESIGN.md.
Accessibility
Every input has an accessible name: a <label for> from the Field, or aria-label for standalone search.
aria-invalid="true" and aria-describedby pointing to the Field error id when invalid.
The help icon is a real <button> with aria-label equal to the tooltip text; the shortcut chip is aria-hidden (announce the shortcut in the tooltip or hint).
Keep native type attributes (email, url, number, search) for keyboards and validation.
The focus ring shows on the wrapper through :focus-within; never remove it on the input without the wrapper replacement.
Forms
Textarea .cn-textareaedit
Multi-line text field with the same ring, radius 8, shadow-xs and focus ring as Input. Padding 12px (sm) or 12×14px (md, lg); the height comes from rows. The wrapper carries the ring; the native textarea is bare.
Use: Free-form text longer than one line: notes, messages, descriptions, addresses. For one line use Input; for rich formatting use an editor component. Pair with Field for label, hint and a character count.
DefaultThree rows, 12×14px padding, gray-300 ring and shadow-xs, radius 8.
HTML
<div class="cn-textarea" data-size="md" data-resize="vertical" style="max-width:480px"><textarea class="cn-textarea__field" rows="3" placeholder="Describe the change for the release notes…" aria-label="Description"></textarea></div>
With contentThe wrapper grows with rows; drag the corner to make it taller.
HTML
<div class="cn-textarea" data-size="md" data-resize="vertical" style="max-width:480px"><textarea class="cn-textarea__field" rows="3" aria-label="Notes">Daniel Costa asked for the onboarding checklist to be split by role. Maya Chen will draft the designer track before the Friday review.</textarea></div>
Sizessm 12px all round; md and lg 12px vertical, 14px horizontal. Text is 16px on lg.
DisabledSame fill and ring at 50% opacity; no resize handle.
HTML
<div class="cn-textarea" data-size="md" data-resize="vertical" style="max-width:480px"><textarea class="cn-textarea__field" rows="3" disabled aria-label="Terms">Standard terms apply to this workspace and cannot be edited after the plan is signed.</textarea></div>
Recipes
With character countThe count lives in the Field hint, right-aligned, tabular numerals. Turn it to fg-danger only when the limit is exceeded.
Shown on your public profile.109 / 240
HTML
<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:480px"><label class="cn-field__label" for="ta-bio">Public description</label><div class="cn-field__control"><div class="cn-textarea" data-size="md" data-resize="vertical"><textarea class="cn-textarea__field" id="ta-bio" rows="3" maxlength="240" aria-describedby="ta-bio-helper">Lumen helps product teams turn research into decisions: interviews, notes and evidence in one workspace.</textarea></div></div><p class="cn-field__helper" id="ta-bio-helper" style="display:flex;justify-content:space-between;gap:var(--cn-space-3)"><span>Shown on your public profile.</span><span class="cn-tabular">109 / 240</span></p></div>
Matrix
size ↓ / resize →
none
vertical
sm
md
lg
Anatomy, props, states, rules
Anatomy
.cn-textarea<div> — Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within).
.cn-textarea__field<textarea> — The native <textarea>. No border, no outline, fills the wrapper. Set rows to size it (3 in forms); the wrapper grows with it and with a vertical resize.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
resize
nonevertical
vertical = the user can drag the corner to make it taller (default for notes and messages); none = fixed height, for inputs with a strict length or inside dense layouts.
States
state
selector
meaning
hover
:hover:not([data-disabled])
Pointer over the field. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring. (native)
focus
:focus-within
The inset ring turns to the brand color and the 4px brand focus ring appears around the wrapper. (native :focus-within on the wrapper)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring; on focus the focus ring turns red. Always accompanied by a Field error. (aria-invalid="true" on the textarea, or data-invalid on the wrapper)
disabled
[data-disabled], &:has(:disabled)
Whole control at 50% opacity, cursor not-allowed, no resize handle. (disabled on the textarea)
Rules
Start at 3 rows (rows="3"); use 4–6 only for the primary text of a screen (a message composer). Never taller than the viewport.
Vertical resize only. Horizontal resize breaks the layout; disable resize entirely (resize="none") when the height is a design constraint.
Always inside a Field with a visible label. Use the hint for the limit and the tone expected ("Visible to the client").
Width comes from the layout (100% of the Field). Set max-width on the Field, never a fixed width on the wrapper.
Show a character count only when there is a hard limit, as "109 / 240" in the hint. Do not block typing at the limit; mark the field invalid.
Sizes match the Inputs around it: md in forms, sm in side panels and comment threads, lg on marketing and auth screens.
Accessibility
Every textarea has an accessible name: a <label for> from the Field, or aria-label when standalone.
aria-invalid="true" and aria-describedby pointing to the Field error id when invalid.
Keep the native resize handle keyboard-independent: the height must never be required to read the content (the textarea scrolls).
A character limit is announced through the hint text; do not rely on maxlength alone.
Forms
Select .cn-selectedit
Native <select> dressed as the reference select trigger: the Input ring, radius 8 and heights (36 / 40 / 44px), an optional leading icon and a gray-500 chevron at the trailing edge. The dropdown itself is the operating system menu.
Use: Choose one option from a short, known list (5–15 items: owner, plan, status, sort order). Under 5 options use Radio or SegmentedControl; for long or searchable lists, icons or supporting text in the rows, use Combobox; for multiple choices use Checkboxes. Pair with Field for label and hint.
Default40px tall, 12px padding, medium text, 16px gray-500 chevron 12px from the edge.
.cn-select<div> — Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within). Position: relative for the icon and chevron.
.cn-select__icon<svg> · optional — Optional leading icon (16px on sm, 20px on md and lg) absolutely placed at the padding edge in gray-500; the text starts 8px after it. Decorative, pointer-events: none.
.cn-select__field<select> — The native <select> with appearance: none. Fills the wrapper; the trailing padding reserves room for the chevron.
.cn-select__chevron<svg> — Chevron-down, 16px (20px on lg), gray-500, absolutely placed 10 / 12 / 14px from the trailing edge. Decorative, pointer-events: none.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
variant
defaultfilled
default = white with the gray-300 ring and shadow-xs (the reference trigger; forms); filled = gray-50 fill without ring or shadow until focus (toolbars, table filters, sort controls — no reference equivalent).
States
state
selector
meaning
hover
:hover:not([data-disabled])
Pointer over the trigger. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring. (native)
focus
:focus-within
Focused or open: the inset ring turns to the brand color and the 4px brand focus ring appears. (native :focus-within on the wrapper)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring; red focus ring on focus. Always accompanied by a Field error. (aria-invalid="true" on the select, or data-invalid on the wrapper)
disabled
[data-disabled], &:has(select:disabled)
Whole control at 50% opacity, cursor not-allowed. Scoped to the <select> so a disabled placeholder <option> does not trigger it. (disabled on the select)
Rules
Use for 5–15 known options. Fewer: Radio or SegmentedControl (all options visible). More, or user-generated lists: Combobox with search.
The first option is either a real default or a disabled placeholder with value="" ("Select a plan"); never "Select…" as a selectable option.
Option labels are nouns in sentence case, 1–4 words; put the unit or price after an em dash ("Team — $99/mo").
Always inside a Field with a visible label, except sort/filter selects in a toolbar (variant="filled", size="sm") whose label is in the option text ("Sort: most recent").
Width comes from the layout; set max-width on the Field for short values (currency, region).
Sizes: md (40px) in forms; sm (36px) in toolbars and tables; lg (44px) on marketing and auth screens. Never change the height with padding.
Keep the native <select>. If the menu needs search, avatars, supporting text or groups with headers, use Combobox.
Accessibility
Every select has an accessible name: a <label for> from the Field or aria-label for toolbar selects.
Use <optgroup label> for grouped options instead of fake heading options.
aria-invalid="true" plus aria-describedby pointing at the Field error id when invalid.
The icon and chevron are aria-hidden and pointer-events: none; the select itself receives the click and the keyboard (Alt+Down / typing to jump).
Forms
Combobox .cn-comboboxedit
Text input that filters a list: an Input-ringed field with a trailing chevron and the reference popover listbox under it (radius 8, shadow-lg, 1px ring, 4px padding) whose rows are 36 / 40 / 44px with a gray-50 highlight and a brand check on the selected one.
Use: Choose one item from a long, searchable or user-generated list (people, workspaces, countries), or when rows need an avatar, icon or supporting text. For 5–15 plain options use Select; for free text with suggestions keep the Input; for multiple picks use a TagInput. Pair with Field for the label.
Closed (default)The Input ring, radius 8 and shadow-xs; a 16px gray-500 chevron 12px from the edge.
Open with optionsMaya is the current value (gray-50 fill and brand check), Daniel is highlighted by the keyboard, Aisha is disabled. Rows are 40px with 24px avatars and muted supporting text.
.cn-combobox<div> — Wrapper (position: relative) that holds the input, the chevron and the listbox. Carries data-state="open|closed", data-invalid, data-disabled.
.cn-combobox__leading-icon<svg> · optional — Optional leading icon (16px on sm, 20px on md and lg) at the padding edge in gray-500; the text starts 8px after it. Decorative.
.cn-combobox__input<input> — The text field with role="combobox": the Input ring, radius 8, shadow-xs; the trailing padding reserves room for the chevron.
.cn-combobox__icon<svg> — Chevron-down, 16px (20px on lg), gray-500, absolutely placed 10 / 12 / 14px from the trailing edge; rotates 180° when open. Decorative, pointer-events: none.
.cn-combobox__listbox<ul> — The popover: white, radius 8, shadow-lg with a 1px gray-200 ring, 4px vertical padding (4–6px horizontal), 4px under the input, scrolls after 224 / 256 / 320px. Hidden unless data-state="open".
.cn-combobox__option<li> — One row: 36 / 40 / 44px, radius 6, padding 8px 10px 8px 8px, 8px gap, medium label. role="option" with a unique id, aria-selected and optional data-highlighted / aria-disabled.
.cn-combobox__option-icon<svg> · optional — Optional leading icon in the row (16px on sm, 20px on md and lg), gray-500.
.cn-combobox__option-avatar<img> · optional — Optional round avatar before the label (20px on sm, 24px on md and lg).
.cn-combobox__option-supporting<span> · optional — Optional muted text after the label (email, team, price), same size, regular weight.
.cn-combobox__option-check<svg> — Check icon (16 / 20px) pushed to the right of the row in the brand color, shown only when aria-selected="true".
.cn-combobox__empty<li> · optional — Muted "No results" row rendered instead of options when the filter matches nothing.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
States
state
selector
meaning
hover
:hover:not([data-disabled])
Pointer over the field. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring. (native)
focus
:focus-within
The inset ring turns to the brand color and the 4px brand focus ring appears. Focus never leaves the input while the list is open. (native :focus-within on the root)
open
[data-state="open"]
The listbox is visible under the input and the chevron points up. (data-state="open" on the root and aria-expanded="true" on the input)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring; red focus ring on focus. Always accompanied by a Field error. (aria-invalid="true" on the input, or data-invalid on the root)
disabled
[data-disabled], &:has(input:disabled)
Whole control at 50% opacity, cursor not-allowed. (disabled on the input)
highlighted
[data-highlighted]
On the __option (not the root): the row under the keyboard cursor or pointer gets the gray-50 fill. Exactly one at a time. (data-highlighted on the option; mirror it with aria-activedescendant on the input)
selected
[aria-selected="true"]
On the __option: the current value, gray-50 fill with the brand check at the end. (aria-selected="true" on the option)
option-disabled
[aria-disabled="true"]
On the __option: not selectable, 50% opacity, cursor not-allowed, skipped by the keyboard. (aria-disabled="true" on the option)
Rules
Use a Combobox when the list is long (more than 15), user-generated, needs search, or when rows need an avatar, icon or supporting text; otherwise a Select. Typing filters the list; the value is always one of the options.
The listbox renders in the flow 4px under the input inside the root. Toggle data-state="open|closed" on the root; the app moves it to a popper layer only when it would be clipped.
Rows are 36 / 40 / 44px (same as the field) with radius 6 and one line of text. Show at most 5–7 before scrolling (224 / 256 / 320px).
Exactly one option is highlighted at a time (data-highlighted) and follows keyboard and pointer; the selected one has aria-selected="true", the gray-50 fill and the brand check on the right.
When nothing matches, render the empty row ("No results for “…”"), never an empty panel and never close the list.
The chevron rotates when open. An optional clear (×) button goes before the chevron and appears only when there is a value.
Placeholder is an instruction with an ellipsis ("Search people…"); the label lives in the Field.
Never use a Combobox for multiple picks; that is a TagInput.
Accessibility
The input has role="combobox", aria-expanded, aria-controls="<listbox id>", aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option id.
The list has role="listbox"; each option has role="option", a unique id and aria-selected. Disabled options use aria-disabled="true" and are skipped by the keyboard.
Keyboard: Down/Up move the highlight, Enter selects, Escape closes and restores the previous value, Home/End jump. Focus stays on the input the whole time.
Announce the result count in a visually hidden live region ("4 results") whenever the filtered list changes.
When invalid, aria-invalid="true" on the input and aria-describedby pointing at the Field error id.
Forms
Checkbox .cn-checkboxedit
Yes/no or multi-select choice: a 16px (sm) or 20px (md) box with a gray-300 ring that fills with the brand color and shows a white check, next to a medium label and a muted supporting line. The native input stays in the DOM (invisible but focusable) so every state is CSS-only via :has().
Use: Independent options that take effect on save (permissions, filters, consent) and multi-select lists. For one-of-many use Radio; for a setting that applies immediately use Switch. Always with a visible label; never a lone box. The card variant turns each option into a selectable bordered card.
.cn-checkbox<label> — The <label> wrapping everything: it is the hit target and gives the input its name. inline-grid: box in column 1, text in column 2, 8px (sm) or 12px (md) apart.
.cn-checkbox__input<input> — Native <input type="checkbox">, visually hidden (opacity 0, positioned over the box) but focusable. Carries checked, disabled, aria-invalid.
.cn-checkbox__control<span> — The visible box: white with a 1px gray-300 inset ring, radius 4 (sm) or 6 (md), nudged 2px down to align with the first text line. aria-hidden; purely decorative.
.cn-checkbox__indicator<svg> — The white check (12px on sm, 14px on md) inside the box. Hidden until checked; replaced by a dash when indeterminate.
.cn-checkbox__label<span> — 14px (sm) or 16px (md) medium text in the default ink. Sentence case, no period, states the positive outcome.
.cn-checkbox__description<span> · optional — Optional supporting line under the label, same size, regular weight, muted: consequence or scope of the option.
Props
prop
values (default outlined)
meaning
size
smmd
sm = 16px box (radius 4) with a 14px label: forms, settings, tables (the reference default); md = 20px box (radius 6) with a 16px label: touch screens, onboarding and prominent options.
variant
defaultcard
default = bare box and text; card = the whole option is a selectable card (white, 1px gray-200 ring, radius 12, 16px padding) whose ring turns into a 2px brand ring when checked. Use cards for plans, roles and add-ons where the description matters.
States
state
selector
meaning
checked
:has(:checked)
Box fills with the brand color and shows the white check. A card gets a 2px brand ring. (checked attribute on the input)
indeterminate
[data-indeterminate], &:has(:indeterminate)
Partial selection (a "Select all" over a mixed group): brand fill with a dash instead of the check. (data-indeterminate on the root, plus input.indeterminate = true in JS)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring on the box, for a required acceptance that was skipped. Pair with a visible error message next to the group. (aria-invalid="true" on the input, or data-invalid on the root)
focus
:has(:focus-visible)
Keyboard focus on the hidden input shows the 4px brand ring on the box (on the whole card in the card variant). (native :focus-visible on the input)
disabled
[data-disabled], &:has(:disabled)
Box at 50% opacity (gray-100 fill when unchecked), cursor not-allowed on the whole row; the text keeps its color. A checked disabled box keeps its check. (disabled on the input)
Rules
Use a checkbox for options that take effect on save. For an immediate effect use Switch; for one-of-many use Radio.
The label states the positive outcome ("Send me a weekly digest"), never a negation ("Do not send…") and never an "Enable/Disable" prefix.
Labels are sentence case without a period; the supporting line is one full sentence with a period.
Stack checkboxes vertically 12px apart (16px between cards). Never place more than two side by side.
Indeterminate is only for a parent that summarizes children ("Select all"). Set input.indeterminate in JS and mirror it with data-indeterminate; clicking it checks everything.
Invalid is reserved for a required acceptance (terms, consent). Put the error message under the group, in fg-danger, and clear it as soon as the box is checked.
sm (16px) in forms, settings and tables; md (20px) on touch screens, onboarding and prominent options. Cards for plans, roles and add-ons where the description matters.
Keep the native input in the DOM. The box is decorative (aria-hidden); never rebuild the checkbox with a div and onClick.
Accessibility
The root is a <label> wrapping the input, so the text is the accessible name and the whole row (or card) is the hit target.
The visible box is aria-hidden; checked, focus and disabled come from the native input via :has(), so assistive tech sees a normal checkbox.
A native checkbox announces "mixed" on its own once input.indeterminate is true; do not add aria-checked to it.
Groups get role="group" (or a <fieldset>) with aria-labelledby pointing at the group label.
Keep descriptions inside the label; if they contain links or run long, move them out and connect them with aria-describedby.
Forms
Radio .cn-radioedit
One-of-many choice: a 16px (sm) or 20px (md) circle with a gray-300 ring that fills with the brand color and shows a white dot, next to a medium label and a muted supporting line. Same anatomy as Checkbox; always used in a named group of 2–5 options.
Use: Pick exactly one option when all options should be visible and compared (billing cycle, priority, delivery method). For more than 5 options use Select; for an on/off setting use Switch; for a view toggle in a toolbar use SegmentedControl. The card variant turns each option into a selectable bordered card.
Default (unchecked)16px circle, gray-300 ring; 14px medium label 8px away.HTML
Plans as cardsCards stack 16px apart; the whole card is the hit target and shows the focus ring.
Plan
HTML
<div class="cn-radio-group" role="radiogroup" aria-labelledby="rg-plan" style="max-width:420px"><div class="cn-radio-group__label" id="rg-plan">Plan</div><label class="cn-radio" data-variant="card" data-size="sm"><input class="cn-radio__input" type="radio" name="plan" value="starter"><span class="cn-radio__control" aria-hidden="true"><span class="cn-radio__indicator"></span></span><span class="cn-radio__label">Starter — $29/mo</span><span class="cn-radio__description">Up to 5 members and 3 projects.</span></label><label class="cn-radio" data-variant="card" data-size="sm"><input class="cn-radio__input" type="radio" name="plan" value="team" checked><span class="cn-radio__control" aria-hidden="true"><span class="cn-radio__indicator"></span></span><span class="cn-radio__label">Team — $99/mo</span><span class="cn-radio__description">Unlimited members, 20 projects and shared libraries.</span></label><label class="cn-radio" data-variant="card" data-size="sm"><input class="cn-radio__input" type="radio" name="plan" value="enterprise"><span class="cn-radio__control" aria-hidden="true"><span class="cn-radio__indicator"></span></span><span class="cn-radio__label">Enterprise</span><span class="cn-radio__description">SSO, audit log and a named contact. Talk to Noah Berg.</span></label></div>
Matrix
variant ↓ / size →
sm
md
default
card
Anatomy, props, states, rules
Anatomy
.cn-radio<label> — The <label> wrapping everything: hit target and accessible name. inline-grid: circle in column 1, text in column 2, 8px (sm) or 12px (md) apart.
.cn-radio__input<input> — Native <input type="radio" name="…">, visually hidden but focusable. Options of one question share the same name.
.cn-radio__control<span> — The visible circle: white with a 1px gray-300 inset ring, radius full, nudged 2px down to align with the first text line. aria-hidden.
.cn-radio__indicator<span> — The white inner dot (6px on sm, 8px on md). Hidden until checked.
.cn-radio__label<span> — 14px (sm) or 16px (md) medium text in the default ink. A noun phrase in sentence case, no period.
.cn-radio__description<span> · optional — Optional supporting line under the label, same size, regular weight, muted: price, consequence or example.
Props
prop
values (default outlined)
meaning
size
smmd
sm = 16px circle with a 14px label: forms, dialogs, tables (the reference default); md = 20px circle with a 16px label: touch screens, onboarding and prominent choices.
variant
defaultcard
default = bare circle and text; card = the whole option is a selectable card (white, 1px gray-200 ring, radius 12, 16px padding) whose ring turns into a 2px brand ring when selected. Use cards for plans and tiers where the description matters.
States
state
selector
meaning
checked
:has(:checked)
Circle fills with the brand color and shows the white dot. A card gets a 2px brand ring. (checked attribute on the input)
invalid
[data-invalid], &:has([aria-invalid="true"])
Red-300 ring on every option of a required group with no selection. Pair with an error message under the group. (aria-invalid="true" on the inputs, or data-invalid on each root)
focus
:has(:focus-visible)
Keyboard focus shows the 4px brand ring on the circle (on the whole card in the card variant). (native :focus-visible on the input)
disabled
[data-disabled], &:has(:disabled)
Circle at 50% opacity (gray-100 fill when unselected), cursor not-allowed on the whole row; the text keeps its color. A checked disabled option keeps its dot. (disabled on the input)
Rules
A radio group has 2–5 options and always one selected (pre-select the safest or most common). No selection is a validation error, not a state.
Wrap options in .cn-radio-group with a label; never a loose row of radios without a visible question.
Options are parallel noun phrases in sentence case ("Monthly", "Annual"), never questions or verbs. Put price and consequence in the supporting line.
Stacked 16px apart by default; horizontal (data-orientation="horizontal", 24px column gap) only for one-word options without descriptions.
If choosing an option reveals more fields, put them directly under that option indented by the label column (8px + circle width), not at the end of the group.
Use SegmentedControl instead when the choice is a view/mode in a toolbar; use Select when there are more than 5 options.
sm (16px) in forms and dialogs; md (20px) on touch screens and onboarding. Cards for plans and tiers where the description matters.
Accessibility
Each option is a <label> wrapping a native <input type="radio">; every input of the group shares the same name so arrow keys move between them.
The group container has role="radiogroup" (or is a <fieldset> with <legend>) and aria-labelledby pointing at the group label.
Keyboard: Tab enters the group on the checked option; Up/Down and Left/Right move and select; Space selects when nothing is checked.
Invalid: aria-invalid="true" on every input plus aria-describedby pointing at the error under the group.
The circle and dot are aria-hidden; state comes from the native input, so no aria-checked is needed.
Forms
Switch .cn-switchedit
On/off toggle that applies immediately: a 36×20 (sm) or 44×24 (md) pill track with a white thumb that slides right while the track turns from gray-100 to the brand color. A slim variant shrinks the track under an overhanging bordered thumb.
Use: Settings that take effect the moment they change (notifications, dark mode, auto-approve) and feature flags. If the change needs a Save button use Checkbox; if it is a choice between two named options use SegmentedControl or Radio.
Off36×20 gray-100 track with a hairline ring; 16px white thumb with shadow-sm.HTML
With supporting textSupporting line: same size, regular weight, muted.HTML
<label class="cn-switch" data-variant="default" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">Auto-approve replies</span><span class="cn-switch__description">Lumen posts follow-ups without waiting for your review when the request only asks for documents.</span></label>
Medium44×24 track with a 20px thumb; 16px text 12px away.HTML
<label class="cn-switch" data-variant="default" data-size="md"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">Share usage analytics</span><span class="cn-switch__description">Helps us prioritise what to build next.</span></label>
SlimA thin track under an overhanging bordered thumb: 32×16 (sm) and 40×20 (md). The thumb border turns brand when on.
Settings rowsText left, switch right, one row per setting with a hairline between rows. The switch keeps a visually hidden label so it stays named.
Email digest
A summary of new comments every weekday at 8:00.
Desktop notifications
Only for projects you follow.
Sounds
Play a chime when Maya Chen mentions you.
HTML
<div style="width:100%;max-width:480px;display:flex;flex-direction:column"><div style="display:flex;align-items:center;justify-content:space-between;gap:var(--cn-space-4);padding:var(--cn-space-4) 0;"><div><div class="cn-text-label-sm">Email digest</div><div class="cn-text-body-md" style="color:var(--cn-color-fg-muted)">A summary of new comments every weekday at 8:00.</div></div><label class="cn-switch" data-variant="default" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label"><span class="cn-sr-only">Email digest</span></span></label></div><div style="display:flex;align-items:center;justify-content:space-between;gap:var(--cn-space-4);padding:var(--cn-space-4) 0;border-top:1px solid var(--cn-color-border-default)"><div><div class="cn-text-label-sm">Desktop notifications</div><div class="cn-text-body-md" style="color:var(--cn-color-fg-muted)">Only for projects you follow.</div></div><label class="cn-switch" data-variant="default" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label"><span class="cn-sr-only">Desktop notifications</span></span></label></div><div style="display:flex;align-items:center;justify-content:space-between;gap:var(--cn-space-4);padding:var(--cn-space-4) 0;border-top:1px solid var(--cn-color-border-default)"><div><div class="cn-text-label-sm">Sounds</div><div class="cn-text-body-md" style="color:var(--cn-color-fg-muted)">Play a chime when Maya Chen mentions you.</div></div><label class="cn-switch" data-variant="default" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" ><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label"><span class="cn-sr-only">Sounds</span></span></label></div></div>
Matrix
variant ↓ / size →
sm
md
default
slim
Anatomy, props, states, rules
Anatomy
.cn-switch<label> — The <label> wrapping everything: hit target and accessible name. inline-grid: track in column 1, text in column 2, 8px (sm) or 12px (md) apart.
.cn-switch__input<input> — Native <input type="checkbox" role="switch">, visually hidden (opacity 0 over the track) but focusable.
.cn-switch__track<span> — The pill: 36×20 (sm) or 44×24 (md) with a 2px inset, radius full, gray-100 with a hairline ring when off, brand when on. Slim: 32×16 / 40×20 with no inset. aria-hidden.
.cn-switch__thumb<span> — White circle (16px sm / 20px md) with shadow-sm that translates by its own width when on. Slim: shadow-xs and a 1px gray-300 border that turns brand when on.
.cn-switch__label<span> — 14px (sm) or 16px (md) medium text in the default ink. Names the thing being turned on ("Email digest").
.cn-switch__description<span> · optional — Optional supporting line under the label, same size, regular weight, muted: what happens when it is on.
Props
prop
values (default outlined)
meaning
size
smmd
sm = 36×20 track with a 16px thumb and a 14px label: settings rows, tables, popovers (the reference default); md = 44×24 track with a 20px thumb and a 16px label: touch screens and prominent settings.
variant
defaultslim
default = the thumb sits inside the track with a 2px inset; slim = a thinner track (32×16 / 40×20) with the bordered thumb overhanging it, for dense rows and table cells.
States
state
selector
meaning
hover
:hover
When on, the track takes the brand hover color (slim: the thumb border does). Off has no hover change. Disabled wins over it. (native :hover on the label)
checked
:has(:checked)
On: the track turns to the brand color and the thumb slides right by its own width. (checked attribute on the input)
focus
:has(:focus-visible)
Keyboard focus shows the 4px brand ring around the track. (native :focus-visible on the input)
disabled
[data-disabled], &:has(:disabled)
Track at 50% opacity, cursor not-allowed on the whole row; the text keeps its color and the on/off position stays readable. (disabled on the input)
Rules
A switch applies immediately; there is no Save button after it. If the change needs a submit, use a Checkbox.
The label names the thing being turned on ("Email digest"), not the action ("Enable email digest") and not a question.
Never use a switch for a choice between two named options (Monthly / Annual): that is a SegmentedControl or a Radio group.
In settings lists the text sits left and the switch right, one row per setting, 16px vertical padding and a hairline between rows.
Do not add "On" / "Off" text next to the track; position and color are the state. Confirm with a toast only when the change has side effects (emails sent, data deleted).
sm (36×20) everywhere by default; md (44×24) on touch screens and prominent settings; slim inside dense rows and table cells.
Motion: thumb and track change together in 150ms; nothing else animates.
Accessibility
The input is a native checkbox with role="switch": screen readers announce on/off and Space toggles it.
The root <label> gives the accessible name. When the text lives elsewhere (settings row), keep a cn-sr-only label inside or use aria-labelledby.
The focus ring appears on the track through :has(:focus-visible); never remove it.
Disabled switches stay readable (track at 50%, text unchanged); say in the supporting line why the setting is locked.
Forms
Slider .cn-slideredit
Native range input with an 8px gray-200 rail, a brand fill up to the current value and a 24px white thumb with a 2px brand ring and shadow-md. The value can sit at the right, under the thumb or in a floating chip above it.
Use: Pick a value from a continuous or coarse range where the feel matters more than the exact figure (a threshold, a budget band, opacity, volume). For exact numbers use Input type="number"; for a few discrete options use SegmentedControl or Radio. Pair with Field for the label.
With value at the right8px gray-200 rail, brand fill, 24px white thumb with a 2px brand ring and shadow-md. Set --cn-slider-fill on the root to (value − min) ÷ (max − min).
In a Field with range captionsLabel from the Field, captions under the track in 12px, hint explains the consequence.
50% · more drafts100% · safer
Drafts below this confidence wait for Sofia Almeida’s approval.
HTML
<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:360px"><label class="cn-field__label" for="sl-threshold">Auto-publish confidence</label><div class="cn-field__control"><div class="cn-slider" data-label="right" style="--cn-slider-fill: 70%"><input class="cn-slider__field" id="sl-threshold" type="range" min="50" max="100" step="5" value="85" aria-describedby="sl-threshold-helper"><output class="cn-slider__value" for="sl-threshold">85%</output></div><div class="cn-text-body-sm" style="display:flex;justify-content:space-between;color:var(--cn-color-fg-subtle);margin-top:var(--cn-space-1)"><span>50% · more drafts</span><span>100% · safer</span></div></div><p class="cn-field__helper" id="sl-threshold-helper">Drafts below this confidence wait for Sofia Almeida’s approval.</p></div>
Matrix
label
right
bottom
top
none
Anatomy, props, states, rules
Anatomy
.cn-slider<div> — Flex row holding the range input and, optionally, the readout. Carries --cn-slider-fill (the value as a percentage), data-label and data-disabled.
.cn-slider__field<input> — The native <input type="range">, 24px tall. Rail and thumb are styled through pseudo-elements; the fill comes from the --cn-slider-fill custom property.
.cn-slider__value<output> · optional — Optional readout (<output for="<input id>">): right = 14px medium tabular text beside the track; bottom = 16px medium text centred under the thumb; top = a floating 12px semibold chip above the thumb (white, radius 8, shadow-lg, hairline ring).
Props
prop
values (default outlined)
meaning
label
rightbottomtopnone
Where the value readout sits. right = beside the track (default, tabular so it never jumps); bottom = centred under the thumb (the reference "bottom"); top = a floating chip above the thumb (the reference "top-floating"); none = hidden (put the value in the Field label instead).
States
state
selector
meaning
focus
:has(:focus-visible)
Keyboard focus: the 4px brand ring around the thumb; the readout turns to the default ink. (native :focus-visible on the input)
active
:has(:active)
While dragging: the thumb shows the grabbing cursor. (native :active on the input)
disabled
[data-disabled], &:has(:disabled)
Whole control at 50% opacity, cursor not-allowed. (disabled on the input)
Rules
Set --cn-slider-fill on the root (or on the input for label="right") to (value − min) ÷ (max − min) × 100% and update it on every input event. Without it the rail shows no fill and the floating labels sit at the start.
Always show the value somewhere: the readout (right, bottom or top), the Field label ("Threshold: 40") or captions. A slider without a number is a toy.
Use step for coarse ranges (budget bands, percentages in fives). Never a slider for exact figures (prices, dates, quantities under 20): use Input type="number".
The right readout is 14px medium, tabular, at least 40px wide so it does not jump between 9 and 10. Format with the unit ($20k, 85%).
Bottom and top labels follow the thumb; leave 32px (bottom) or 40px (top) of room, which the variant reserves with a margin.
Width comes from the Field: minimum 160px, ideal 240–360px, never full width on desktop.
One slider per row. A two-thumb range is a separate component (RangeSlider), not two sliders side by side.
Motion: none on the thumb position (it follows the pointer); only the focus ring transitions (100ms).
Accessibility
Keep the native <input type="range">: it exposes role slider with min, max and the current value for free.
Give it an accessible name (Field label with for, or aria-label) and aria-valuetext when the number needs a unit ("$20,000", "7 of 10").
Keyboard: arrows move one step, Page Up/Down ten steps, Home/End jump to the ends. Do not override these.
The <output for> ties the readout to the input; update its text whenever the value changes.
The 4px focus ring on the thumb must stay visible in both themes; never set outline: none without the ring.
Forms
FileDropzone .cn-file-dropzoneedit
Upload target: a white card with a 1px gray-200 ring and radius 12, a centered 40px featured icon, "Click to upload or drag and drop" and a hint of accepted types. The native file input covers the whole zone, so click and drop work without extra JS. Selected files list under the text as bordered cards with progress and a delete button.
Use: Any file upload in a form or dialog (documents, spreadsheets, a logo). For a single small image next to a preview use size="sm". Not for pasting text or URLs, and not as a full-page drop overlay (that is a separate layer).
DefaultWhite, 1px gray-200 ring, radius 12, 16×24 padding; 40px modern featured icon; "Click to upload" in the brand link color.
Click to upload or drag and dropPDF, DOCX or FIG (max. 25 MB)
With files (complete, uploading, failed)Rows are bordered cards with a 40px file icon, medium name, size · status, an 8px progress bar and a utility delete button; a failed row gets a 2px red ring and "Try again".
Click to upload or drag and dropPDF, DOCX or FIG (max. 25 MB)
Click to upload or drag and dropPDF, DOCX or FIG (max. 25 MB)
md
Click to upload or drag and dropPDF, DOCX or FIG (max. 25 MB)
Anatomy, props, states, rules
Anatomy
.cn-file-dropzone<div> — The zone: white, 1px gray-200 inset ring, radius 12, padding 16×24 (md) or 16 (sm), centered column with a 12px gap. Carries data-state="idle|dragover", data-invalid, data-disabled.
.cn-file-dropzone__input<input> — Native <input type="file"> stretched over the whole zone at opacity 0. Carries accept, multiple, disabled, aria-invalid.
.cn-file-dropzone__icon<svg> · optional — The upload-cloud icon. Put it inside a FeaturedIcon (theme modern, tone gray, size md = 40px, radius 8); on its own it renders as a 20px muted icon.
.cn-file-dropzone__text<div> — Centered text block: the title line and the hint, 4px apart.
.cn-file-dropzone__title<span> — The first line, 14px muted: the action ("Click to upload") followed by "or drag and drop" (or "Release to upload" while dragging).
.cn-file-dropzone__action<span> — The brand link-colored words inside the title ("Click to upload"): 14px semibold, underlined when the zone is hovered. Not a button — the input above it takes the click.
.cn-file-dropzone__hint<span> — 12px muted line: accepted types and size limit ("SVG, PNG, JPG or GIF (max. 800×400px)"). Turns to the danger color and carries the rejection reason when invalid.
.cn-file-dropzone__files<ul> · optional — List of selected files under the text, full width, 12px between rows, raised above the input so its buttons are clickable.
.cn-file-dropzone__file<li> · optional — One file card: white, 1px gray-200 ring, radius 12, 16px padding, 12px gap between icon, body and actions. data-state="uploading|complete|failed"; failed rows get a 2px red ring.
.cn-file-dropzone__file-icon<svg> · optional — 40×40 file-type icon, muted, does not shrink.
.cn-file-dropzone__file-body<div> · optional — Name, meta row, progress or retry stacked; takes the remaining width.
.cn-file-dropzone__file-name<span> · optional — The file name, 14px medium, truncated with an ellipsis.
.cn-file-dropzone__file-meta<div> · optional — Row 2px under the name: size, a 1×12px divider and the status, 8px apart, 14px muted.
.cn-file-dropzone__file-divider<span> · optional — A 1×12px gray-300 rule between size and status.
.cn-file-dropzone__file-status<span> · optional — 16px icon + word, 14px medium, 4px apart: "Uploading…" muted, "Complete" green, "Failed" red (colors follow data-state on the row).
.cn-file-dropzone__file-progress<div> · optional — Row 4px under the meta: a Progress bar (size lg = 8px track) with the percentage at the right, 12px apart.
.cn-file-dropzone__file-percent<span> · optional — The percentage next to the bar, 14px medium, tabular.
.cn-file-dropzone__file-retry<div> · optional — Row 6px under the meta on failed rows holding a danger-link sm "Try again" button.
.cn-file-dropzone__file-actions<div> · optional — Top-right slot for the utility xs delete button, pulled 8px into the padding so the icon aligns with the content edge.
Props
prop
values (default outlined)
meaning
size
smmd
md = padding 16×24 with the 40px featured icon (documents in forms and dialogs, the reference dropzone); sm = padding 16 all round for a single avatar, logo or attachment next to a preview.
States
state
selector
meaning
dragover
[data-state="dragover"]
A file is being dragged over the zone: the ring becomes 2px in the brand color. (data-state="dragover" on the root (set on dragenter, cleared on dragleave/drop))
hover
:hover:not([data-disabled])
Pointer over the zone: the action words underline, like a link. Nothing else changes. (native :hover)
focus
:has(> input:focus-visible)
Keyboard focus on the file input (not on a row button) shows the 4px brand ring around the zone. (native :focus-visible on the input)
invalid
[data-invalid], &:has([aria-invalid="true"])
A rejected file (type or size): the hint turns to the danger color and carries the reason. (aria-invalid="true" on the input, or data-invalid on the root)
disabled
[data-disabled], &:has(> input:disabled)
Gray-50 fill, cursor not-allowed, the featured icon at 50%, no drop. Scoped to the file input so a disabled row button does not trigger it. (disabled on the input)
file-failed
[data-state="failed"]
On a __file row: 2px red ring, red status, "Try again" instead of the progress bar. (data-state="failed" on the row)
Rules
The whole area is the target: the file input covers the root, so a click anywhere opens the picker and a drop anywhere works. Do not add a separate "Browse" button.
Title copy is "Click to upload" (brand link) + "or drag and drop"; while dragging over it may change to "Release to upload". Under 768px the second half may be dropped ("Click to upload and attach files").
The hint always states accepted types and the size limit ("PDF, DOCX or FIG (max. 25 MB)"); when invalid it starts with the rejected file and keeps the same constraints in one sentence.
Set data-state="dragover" on dragenter and clear it on dragleave and drop. The ring changes in 100ms; nothing pulses.
Selected files list as cards 12px apart with name, size · status, an 8px progress bar with the percentage and a utility xs delete button. Failed rows set data-state="failed", get a 2px red ring and a danger-link "Try again".
md in forms and dialogs, max-width 512px; sm for one small asset (avatar, logo) placed next to its preview.
Never a full-page dropzone inside the app shell; use a page-level drop overlay for that and keep this component in the form.
Accessibility
The native <input type="file"> stays focusable: Tab reaches it, Enter or Space open the picker, and the ring shows on the zone through :has(:focus-visible).
Give the input an accessible name that says what to upload ("Upload files"): a Field label with for/id, or aria-label.
Use accept and multiple on the input so the OS picker and assistive tech know the constraints; repeat them in the hint.
Delete buttons carry aria-label "Delete <file name>"; progress bars carry aria-label and aria-valuenow; announce completion or failure in a live region.
Drag and drop is an enhancement: the picker must always work, including on touch devices.
Forms
ColorPicker .cn-color-pickeredit
A compact panel for choosing a color: an 8-column grid of round swatches, and a hex Input with a preview swatch and an eyedropper button. The selected swatch shows an action-colored ring.
Use: Use for brand colors, labels, tags and chart series where a curated palette is enough. Offer the hex field for exact values and the eyedropper when the browser supports it. Not for full-spectrum editing (a design tool); for a fixed set of 3–6 colors use a Radio group of swatches inline.
Swatch grid with hex fieldSixteen preset swatches in two rows; Brand 600 is selected and previewed in the hex field. Swatch colors are inline backgrounds using the primitive scale variables.
.cn-color-picker<div> — The panel (flex column, gap space.3): 280px wide (240 on sm), padding space.4, surface-raised, hairline, radius panel, shadow-lg. role="group" with an aria-label. Carries data-size.
.cn-color-picker__swatches<div> — 8-column grid, gap space.2. role="listbox" aria-label "Preset colors".
.cn-color-picker__swatch<button> — One 24px round swatch (20px on sm) with a hairline; its color is an inline background. role="option" aria-selected, aria-label naming the color. With data-preview it is the 16px preview inside the hex field.
.cn-color-picker__field<div> — Row (flex, gap space.2) with the hex Input (size sm, leading preview swatch) and the eyedropper button.
.cn-color-picker__eyedropper<button> — Square outline icon button (control height sm) with a pipette icon. aria-label "Pick a color from the screen". Hide it when the EyeDropper API is unavailable.
Props
prop
values (default outlined)
meaning
size
smmd
md = 24px swatches in a 280px panel (default); sm = 20px swatches in a 240px panel for popovers inside dense settings.
States
state
selector
meaning
selected
.cn-color-picker__swatch[aria-selected="true"]
On the __swatch: a 2px action-colored ring (box-shadow, offset by the panel surface) marks the current color. Styled in extraCss because it lives on a child. (aria-selected="true" on the swatch)
swatchHover
.cn-color-picker__swatch:hover
On the __swatch: the hairline strengthens. Styled in extraCss. (native :hover on a swatch)
swatchFocus
.cn-color-picker__swatch:focus-visible
On the __swatch: the focus ring. Styled in extraCss. (native :focus-visible on a swatch)
eyedropperHover
.cn-color-picker__eyedropper:hover
On the __eyedropper: subtle fill and default ink. Styled in extraCss. (native :hover on the eyedropper)
Rules
Exactly 8 swatches per row and 1–3 rows (8–24 colors). Order them by hue family, darkest to lightest, neutrals first.
The swatch color is the only inline style allowed (style="background:…"), preferably a primitive scale variable so the palette follows the theme.
Selection is a ring, not a checkmark or a size change: aria-selected="true" draws the 2px action ring offset by the panel surface.
The hex field always mirrors the selection: clicking a swatch writes its hex; typing a valid hex selects a matching swatch or clears the selection.
Accept #RGB and #RRGGBB, normalize to uppercase #RRGGBB on blur, and mark the field invalid (with a message) otherwise.
Show the eyedropper only when window.EyeDropper exists; never render a disabled eyedropper.
The panel is statically positioned by the component; the app anchors it under its trigger (a swatch button or an Input) with an 8px offset and closes it on Escape.
One ColorPicker per popover; never inline in a form, where a Radio group of swatches is lighter.
Accessibility
The swatch grid is role="listbox" with role="option" swatches; each has an aria-label naming the color ("Brand 600") and aria-selected. Arrow keys move between swatches, Enter or Space select.
The hex input has an accessible name ("Hex color") and aria-invalid plus aria-describedby when the value is not a valid hex.
The preview swatch inside the field is aria-hidden; the hex text is the accessible value.
The eyedropper is a real <button> with aria-label; it announces the picked color by updating the hex input.
Color is never the only cue for selection: the ring and the hex value both change.
Forms
DatePicker .cn-date-pickeredit
A calendar panel: month header with ghost prev/next buttons, a weekday row, a 7-column grid of round day buttons, and a footer with the typed date and Cancel / Apply. Selected days fill with the action color, ranges tint the days between, today carries a dot. An optional presets column offers shortcuts.
Use: Pick one date or a start–end range: due dates, report periods, filters by period. The panel opens from a Button (calendar icon + the formatted date) or from an Input with a leading calendar icon. For a time or a date-time use an Input with the right type; for recurring rules use a form.
Single dateSeptember 2026: the 18th is selected, today (the 11th) carries the dot, Aug 31 and Oct 1–4 are outside days.
.cn-date-picker<div> — The floating panel (row): surface-raised, hairline, radius panel, shadow-lg. role="dialog" with an aria-label. Holds an optional presets column and the calendar column. Carries data-variant and data-size.
.cn-date-picker__presets<div> · optional — Left column of shortcut buttons (Today, Yesterday, This week, Last week, This month, Last month), separated from the calendar by a hairline. Only in the with-presets variant.
.cn-date-picker__preset<button> · optional — One shortcut row, 36px, body-sm, radius md; aria-current="true" on the active one.
.cn-date-picker__calendar<div> — The calendar column (flex column) wrapping header, weekdays, grid and footer. Width = 7 days + 2 × space.6 (328px on md, 300px on sm).
.cn-date-picker__header<div> — Row with the previous button, the month name and the next button.
.cn-date-picker__month<div> — The month and year ("September 2026"), label-md, aria-live="polite".
.cn-date-picker__nav<button> — 32px ghost icon button with a chevron; aria-label "Previous month" / "Next month".
.cn-date-picker__grid<div> — 7-column grid of day buttons with a 4px row gap. role="group" named after the month (or a real role="grid" with rows when built with JS).
.cn-date-picker__day<button> — One day: 40×40 (36 on sm), body-sm, radius full. Attributes: aria-selected="true" (chosen), data-range="start|middle|end", data-today, data-outside (previous/next month), disabled.
.cn-date-picker__footer<div> — Top hairline; the typed date in a small Input with a calendar icon, then Cancel (outline) and Apply (primary), size sm.
Props
prop
values (default outlined)
meaning
variant
singlerangewith-presets
single = one date, one Apply; range = a start and an end day with the days between tinted, the footer input shows "Sep 8 – Sep 14, 2026"; with-presets = a range picker with a left column of shortcuts (Today … Last month) for filters by period.
size
smmd
md = 40px days, 328px panel (default); sm = 36px days, 300px panel for popovers inside dense toolbars.
On the __day: subtle fill under the pointer. Styled in extraCss because it lives on a child. (native :hover on a day)
dayFocus
.cn-date-picker__day:focus-visible
On the __day: the focus ring (roving tabindex, arrow keys move it). Styled in extraCss. (native :focus-visible on a day)
today
.cn-date-picker__day[data-today]
On the __day: semibold with a 4px action-colored dot under the number. Styled in extraCss. (data-today and aria-current="date" on the day)
selected
.cn-date-picker__day[aria-selected="true"]
On the __day: action fill, on-action text, medium weight. The start and end of a range are also selected. Styled in extraCss. (aria-selected="true" on the day)
range
.cn-date-picker__day[data-range]
On the __day: days inside a range take the subtle action tint and lose their radius; data-range="start" keeps the left radius, "end" the right one. Styled in extraCss. (data-range="start|middle|end" on the day)
outside
.cn-date-picker__day[data-outside]
On the __day: days of the previous/next month in fg-subtle. Styled in extraCss. (data-outside on the day)
dayDisabled
.cn-date-picker__day:disabled
On the __day: outside the allowed period, disabled ink, no hover. Styled in extraCss. (disabled on the day)
presetCurrent
.cn-date-picker__preset[aria-current="true"]
On the __preset: the active shortcut, subtle fill and medium weight. Styled in extraCss. (aria-current="true" on the preset)
Rules
Weeks start on Monday and every month renders 6 rows at most, padded with outside days (data-outside) so the panel height does not jump between months.
Today always carries the dot (data-today + aria-current="date"), even when selected or inside a range.
A range is start + end with aria-selected="true" and data-range="start|end"; the days between get data-range="middle". The footer input shows the range as "Sep 8 – Sep 14, 2026".
The footer is mandatory: the typed date in a small Input (with a leading calendar icon), Cancel (outline) and Apply (primary), size sm. Selection is committed by Apply, not by clicking a day.
Presets are for filters by period, not for forms; the list is fixed (Today, Yesterday, This week, Last week, This month, Last month) and the active one has aria-current="true".
Dates in the input and in triggers use the "Sep 11, 2026" format; never numeric-only dates that differ by locale.
The panel is statically positioned by the component; the app anchors it under its trigger with an 8px offset (Popover rules) and closes it on Escape and outside click.
Sizes: md by default; sm only inside dense toolbars. Do not scale the panel by other means.
Accessibility
The panel is role="dialog" with an aria-label; focus moves into it on open (to the selected day or today) and returns to the trigger on close.
Days use a roving tabindex: one day is tabbable, Arrow keys move by day and week, Home/End jump to the row ends, PageUp/PageDown change the month.
Each day button needs a full accessible name ("Tuesday, September 8, 2026") via aria-label; the visible number alone is ambiguous.
The month name has aria-live="polite" so navigating months is announced; the nav buttons have aria-labels.
Disabled days stay in the DOM with disabled; explain the constraint near the trigger ("Deliveries start tomorrow").
Forms
FilterBar .cn-filter-baredit
The toolbar above a table or list: a 320px search, dropdown filter buttons with chevrons, a date-range button, sort, a list/grid toggle, the result count, and a wrapping row of active-filter chips with an × each. Composes Input, Button and Tabs; adds only the chips and the view toggle.
Use: Use above any collection view (customers, invoices, files) that can be searched, filtered or re-sorted. Not for form fields (Field), not for page-level tabs (Tabs on their own), and not inside a Card header (put a single search Input there).
SimpleComponent previewSearch, three filter menus and the count in one run.
.cn-filter-bar<div> — The bar: flex row that wraps, 12px gaps, role="search" or a labelled toolbar. Width comes from the layout.
.cn-filter-bar__search<div> · optional — Wrapper for a sm Input with a leading search icon; 320px wide, shrinks to 200px.
.cn-filter-bar__group<div> · optional — A cluster of outline sm Buttons with trailing chevrons that open filter menus (Status, Plan, Owner).
.cn-filter-bar__chips<div> · optional — The wrapping row of active filters. Takes the full width so it sits under the controls.
.cn-filter-bar__chip<span> · optional — One active filter: 28px pill with a hairline, "Key: value" and a 16px × remove button.
.cn-filter-bar__chip-key<span> · optional — The muted "Status:" prefix inside a chip.
.cn-filter-bar__chip-remove<button> · optional — The 16px × that removes this filter. aria-label "Remove filter Status: Active".
.cn-filter-bar__date<div> · optional — Wrapper for the date-range button: outline sm Button with a leading calendar icon and a trailing chevron that opens a DatePicker.
.cn-filter-bar__sort<div> · optional — Wrapper for the sort control: an outline sm Button ("Sort: Newest") that opens a Menu of radio items.
.cn-filter-bar__view<div> · optional — The list/grid toggle: a hairline box of 32px icon buttons, role="group".
.cn-filter-bar__view-item<button> · optional — One icon button inside the view toggle; aria-pressed="true" on the active layout, aria-label naming it.
.cn-filter-bar__count<span> · optional — The result count in body-sm muted ("128 results"), aria-live so filtering announces the new number.
.cn-filter-bar__clear<div> · optional — Wrapper for the link sm "Clear filters" Button. Only rendered when at least one filter is active.
.cn-filter-bar__tabs<div> · optional — Wrapper for a Tabs component (underline for segments of the collection, pill for quick date ranges).
.cn-filter-bar__end<div> · optional — The trailing cluster (count, sort, view, clear). With align="between" it is pushed to the right edge.
Props
prop
values (default outlined)
meaning
variant
simplewith-chipswith-datewith-tabsdense
simple = search + filter buttons + count (most tables). with-chips = the same plus a second line of active-filter chips with × and a Clear link (when filters persist). with-date = quick-range pill Tabs + a date-range button for time-based data (invoices, analytics). with-tabs = underline Tabs that segment the collection ("All / Active / Trial") with search on the right. dense = 8px gaps and 24px chips for toolbars inside drawers and cards.
align
startbetween
start = everything flows from the left in one run. between = the end cluster (count, sort, view) is pushed to the right edge, leaving the middle empty. Use between on full-width pages, start inside narrower panels.
States
state
selector
meaning
chipRemoveHover
.cn-filter-bar__chip-remove:hover
Pointer over a chip's × (on the button; extraCss): muted fill, ink icon. (native :hover on .cn-filter-bar__chip-remove)
chipRemoveFocus
.cn-filter-bar__chip-remove:focus-visible
Keyboard focus on a chip's × shows the focus ring (extraCss). (native :focus-visible on .cn-filter-bar__chip-remove)
viewPressed
.cn-filter-bar__view-item[aria-pressed="true"]
The active layout in the view toggle (on the item; extraCss): subtle fill, ink icon. (aria-pressed="true" on .cn-filter-bar__view-item)
viewHover
.cn-filter-bar__view-item:hover
Pointer over a layout button: subtle fill (extraCss). (native :hover on .cn-filter-bar__view-item)
viewFocus
.cn-filter-bar__view-item:focus-visible
Keyboard focus on a layout button: an inset 2px ring (the toggle clips overflow) (extraCss). (native :focus-visible on .cn-filter-bar__view-item)
Rules
Order from left to right: search, filter menus, date range, then the end cluster: count, sort, view toggle. Never put the count first.
Filter buttons are outline sm Buttons with a trailing chevron and open a Menu; the button label names the field ("Status"), never the value.
Chips show "Key: value" and only appear for active filters; the Clear link appears with the first chip and disappears with the last.
Search is 320px on pages, shrinks to 200px, and is the only Input in the bar. Put more fields in a filter Drawer.
The count uses the exact number and the noun ("128 results", "12 files") and updates live; no "Showing" prefix.
The view toggle has two or three layouts at most, icon-only, with one aria-pressed="true".
Sort is a button labelled "Sort: {option}" that opens a Menu of radio items; never a bare Select in the bar.
align="between" on full-width pages; align="start" inside panels narrower than 720px, where the bar wraps anyway.
dense only inside drawers, cards and split views; on a page the default heights keep 36px targets.
Accessibility
Root is role="search" when it contains the search field, otherwise role="toolbar"; give it an aria-label naming the collection.
Filter buttons expose aria-haspopup="menu" and aria-expanded; the date button aria-haspopup="dialog".
Chip × buttons have aria-label "Remove filter {Key}: {value}"; removing one moves focus to the next chip or to the search field.
The view toggle is role="group" with aria-label; each item has aria-pressed and an aria-label naming the layout.
The count carries aria-live="polite" so screen readers hear the new number after filtering; keep the text short.
Forms
InputGroup .cn-input-groupedit
An Input with addons attached to one or both ends: a muted text prefix or suffix ("https://", "USD"), a bare select, a leading icon or a trailing Button. One hairline box, one shadow, one focus ring around everything.
Use: Use when a fixed part of the value belongs to the field: a protocol, a domain, a unit, a currency or a region selector, or when a "Copy" action must sit inside the field. For a lone prefix text the Input affix is enough; for a numeric stepper use NumberInput; for tokens use TagsInput. Pair with Field for the label.
Leading text addon"https://" sits in the muted addon; the user types only the host.
.cn-input-group<div> — The bordered wrapper (flex, stretch). Draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Carries data-size, data-invalid, data-disabled.
.cn-input-group__icon<svg> · optional — Decorative 20px icon: a leading icon before the field, or the chevron that follows a select addon. aria-hidden, pointer-events none.
.cn-input-group__addon<span> · optional — Static text addon such as "https://" or "USD": fg-muted, padding-inline space.3, separated from the field by a hairline. Leading when placed before the field, trailing when after.
.cn-input-group__select<select> · optional — A bare native <select> used as an addon (currency, region, protocol), followed by an icon element that draws the chevron. Leading or trailing.
.cn-input-group__field<input> — The native <input>: no border, no outline, fills the remaining width.
.cn-input-group__button<button> · optional — A trailing Button (class cn-button, variant ghost, size sm) that also carries this part so it loses its radius and stretches to the group height. One per group, always last.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
States
state
selector
meaning
hover
:hover:not([data-disabled])
Border strengthens on the whole group. (native :hover)
focus
:focus-within
Border becomes the action color and the ring appears around the whole group, whichever child has focus. (native :focus-within on the wrapper)
invalid
[data-invalid], &:has([aria-invalid="true"])
Danger border, danger ring on focus. Always accompanied by a Field error. (aria-invalid="true" on the input, or data-invalid on the wrapper)
disabled
[data-disabled], &:has(input:disabled)
Grey fill, disabled text on every addon, not interactive. The trailing Button keeps full opacity but takes the disabled color. (disabled on the input (and on the button))
Rules
One border for the whole group: the wrapper draws it; addons, the select and the button never draw their own outer border, only the hairline that separates them from the field.
Text addons hold what the user must not type: protocol, domain, unit, currency code. If the addon changes the meaning of the value (currency, region), make it a select addon instead.
At most one addon per side, and at most one Button per group, always trailing. A group with more than three children is a toolbar, not a field.
The trailing Button is a ghost cn-button, size sm, whatever the group size; it also carries the __button part so it stretches and loses its radius.
Addon labels are 1–2 words or a code ("USD", "@lumen.co", "https://"); never a full sentence.
Sizes: md in forms; sm inside toolbars and tables; lg on marketing and auth screens.
Width comes from the layout. The field takes the remaining width; addons keep their intrinsic width.
Error state must show a Field error under the group; color alone is not enough.
Accessibility
The input has an accessible name (label for / aria-label). A text addon is visual: repeat its meaning in the label when it matters ("Website (https://)").
A select addon needs its own aria-label ("Currency"); the chevron svg is aria-hidden.
The trailing button is a real <button type="button"> with a verb label; it is a separate Tab stop after the input.
aria-invalid="true" and aria-describedby pointing at the Field error id when invalid.
Focus is shown on the whole group (:focus-within); the button shows an inset ring so it stays visible inside the clipped wrapper.
Forms
MultiSelect .cn-multi-selectedit
A Combobox that keeps several values: chosen items sit as chips inside the field, and the listbox below shows a checkbox square on every option so the selection reads as a list of ticks. Typing filters; Enter toggles; Backspace removes the last chip.
Use: Pick several items from a known list: assignees, tags from a fixed set, regions, products. For free-typed values use TagsInput; for one value use Select or Combobox; for 2–6 options that fit on screen use a Checkbox group. Pair with Field for the label.
Closed with valuesChips inside the box, the input after them, the chevron at the end of the first line.
.cn-multi-select<div> — Wrapper (position: relative) holding the chips box and the listbox. Carries data-size, data-state="open|closed", data-invalid, data-disabled.
.cn-multi-select__chips<div> — The bordered field box (flex, wrap, min-height = control height): draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Holds the chips, the input and the chevron.
.cn-multi-select__chip<span> — One selected value: surface fill, hairline, radius md, 24px tall, body-sm, with its remove button.
.cn-multi-select__chip-remove<button> — 16px × button at the end of a chip. aria-label "Remove {value}".
.cn-multi-select__field<input> — The bare text input with role="combobox" (flex 1, min-width 80px). Filters the list; never holds a committed value.
.cn-multi-select__icon<svg> — Chevron-down absolutely placed at the end of the first line; rotates when open. Decorative.
.cn-multi-select__listbox<ul> — The panel under the box: surface-raised, hairline, radius panel, shadow-lg, padding space.1, 36px rows, scrolls after 256px. Hidden unless the root has data-state="open". role="listbox" aria-multiselectable="true".
.cn-multi-select__option<li> — One row: checkbox square, then the label. role="option" with a unique id and aria-selected; data-highlighted for the keyboard cursor; aria-disabled="true" when not selectable.
.cn-multi-select__option-check<span> — 16px checkbox square at the start of the row (hairline, radius sm) that fills with the action color and shows the check when the option is selected. Contains the check svg.
.cn-multi-select__empty<li> · optional — Muted "No results" row rendered instead of options when the filter matches nothing.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
States
state
selector
meaning
hover
:hover:not([data-disabled])
Chips box border strengthens. (native :hover)
focus
:focus-within
Chips box border becomes the action color and the ring appears. Focus stays on the input while the list is open. (native :focus-within on the root)
open
[data-state="open"]
The listbox is visible under the box and the chevron points up. (data-state="open" on the root and aria-expanded="true" on the input)
invalid
[data-invalid], &:has([aria-invalid="true"])
Required selection missing or over the limit: danger border, danger ring on focus. Pair with a Field error. (aria-invalid="true" on the input, or data-invalid on the root)
disabled
[data-disabled], &:has(input:disabled)
Grey fill, disabled chips and chevron, not interactive. (disabled on the input (and on the chip remove buttons))
highlighted
.cn-multi-select__option[data-highlighted]
On the __option: the row under the keyboard cursor or pointer, bg-subtle. Exactly one at a time. Styled in extraCss. (data-highlighted on the option; mirror it with aria-activedescendant on the input)
selected
.cn-multi-select__option[aria-selected="true"]
On the __option: a chosen value; the checkbox square fills with the action color and shows the check, the label turns medium. Styled in extraCss. (aria-selected="true" on the option)
option-disabled
.cn-multi-select__option[aria-disabled="true"]
On the __option: not selectable, disabled ink, skipped by the keyboard. Styled in extraCss. (aria-disabled="true" on the option)
Rules
Values come from the list only; typing filters, it never creates. If users must add their own values, use TagsInput.
Selected items render as chips in the box in selection order and stay ticked in the list; removing a chip unticks the option and vice versa.
The listbox renders in the flow directly under the box (margin-top space.1) inside the root; toggle data-state="open|closed" on the root. Move it to a popper layer only when it would be clipped.
Options are 36px rows with a 16px checkbox square first; show at most 7 before scrolling (max-height 256px).
Exactly one option is highlighted at a time (data-highlighted) and follows keyboard and pointer; Enter or Space toggles it without closing the list.
When nothing matches, render the empty row ("No regions match “…”"); never an empty panel and never close the list.
State the limit in the helper text when one exists ("Up to 5 reviewers") and mark the box invalid when it is exceeded, not silently ignore the click.
Sizes: md in forms; sm in filter bars; lg on onboarding screens. Chips are 20 / 24 / 28px tall.
Accessibility
The input has role="combobox", aria-expanded, aria-controls="<listbox id>", aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option id.
The list has role="listbox" aria-multiselectable="true"; each option has role="option", a unique id and aria-selected="true|false". Disabled options use aria-disabled="true".
Keyboard: Down/Up move the highlight, Enter or Space toggle it, Escape closes, Backspace on an empty input removes the last chip. Focus stays on the input the whole time.
Each chip remove button is a real <button> with aria-label "Remove {value}"; announce changes in a live region ("Daniel Costa removed, 1 selected").
The checkbox square is decorative (aria-hidden); the state is carried by aria-selected on the option.
Forms
NumberInput .cn-number-inputedit
A numeric Input with an optional unit suffix and, in the stepper variant, two square ghost buttons (−, +) separated by hairlines at the end. The native spinner is hidden; the wrapper draws the border and focus ring.
Use: Quantities, seats, weights, percentages and other numbers the user adjusts by small steps. For free numeric text (an invoice total, a phone number) use Input with inputmode; for a currency amount with a selector use InputGroup; for a value on a range use Slider.
Stepper (default)Value on the left, −/+ at the end. Arrow keys step the value from the keyboard.
.cn-number-input<div> — The bordered wrapper (inline-flex, stretch): background, hairline, radius, shadow-xs, focus ring on :focus-within. Carries data-variant, data-size, data-invalid, data-disabled.
.cn-number-input__field<input> — The native <input type="number" inputmode="decimal">. No border, tabular numerals, spinner hidden.
.cn-number-input__unit<span> · optional — Muted suffix after the value ("kg", "%", "seats"). Not part of the value.
.cn-number-input__decrement<button> · optional — Square ghost button with a minus icon, separated by a hairline. tabindex="-1" (keyboard users use the arrow keys). Disable it at min.
.cn-number-input__increment<button> · optional — Square ghost button with a plus icon, last child. Disable it at max.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
variant
stepperplain
stepper = value plus the −/+ buttons (quantities, seats, anything adjusted in small steps); plain = value and unit only, no buttons (percentages, weights and other typed numbers where stepping makes no sense).
States
state
selector
meaning
hover
:hover:not([data-disabled])
Border strengthens. (native :hover)
focus
:focus-within
Border becomes the action color and the ring appears (also when a stepper button is focused). (native :focus-within on the wrapper)
invalid
[data-invalid], &:has([aria-invalid="true"])
Out of range or not a number: danger border, danger ring on focus. Pair with a Field error. (aria-invalid="true" on the input, or data-invalid on the wrapper)
disabled
[data-disabled], &:has(input:disabled)
Grey fill, disabled text and buttons, not interactive. (disabled on the input (and on both buttons))
stepHover
.cn-number-input__decrement:hover:not(:disabled)
On a stepper button: subtle fill and default ink. Same for __increment. Styled in extraCss. (native :hover on a stepper button)
stepDisabled
.cn-number-input__decrement:disabled
On a stepper button at the min (or __increment at the max): disabled ink, no hover. Styled in extraCss. (disabled on the button)
Rules
Use type="number" with inputmode="decimal" and real min / max / step attributes; the stepper buttons call stepDown() / stepUp() on the input, never their own arithmetic.
Disable the decrement at min and the increment at max instead of clamping silently.
The unit is a suffix after the value ("kg", "%", "seats"); a currency with a selector is an InputGroup, not a NumberInput.
stepper for small-step adjustments (quantities, seats, days); plain for typed values (percentages, weights, prices). Do not add steppers to a value nobody increments by one.
Numbers are right-aligned only in tables; inside forms they stay left-aligned like every other field.
Width comes from the layout; cap it with max-width on the Field (120–240px) since numbers are short.
Sizes: md in forms; sm in tables and toolbars; lg on marketing screens.
Invalid must be accompanied by a Field error that states the allowed range ("Between 1 and 200").
Accessibility
The input has an accessible name (label for / aria-label); the stepper buttons say "Increase {name}" / "Decrease {name}".
Stepper buttons use tabindex="-1": keyboard users step with Arrow Up/Down (native) and never need to tab through the buttons.
Keep the native input so aria-valuemin / max come for free; when invalid set aria-invalid="true" and aria-describedby to the error id.
Focus is shown on the wrapper (:focus-within); a clicked stepper shows an inset ring so it stays visible inside the clipped wrapper.
Forms
PinInput .cn-pin-inputedit
A row of square one-character cells for a verification code. Each cell is a native input with a "0" placeholder; the typed digit renders large, centered, in the action color. Focus moves cell to cell.
Use: Use for one-time passcodes, 2FA codes and short confirmation codes (4 or 6 digits) sent by email or SMS. Not for phone numbers, card numbers or anything the user copies from a document: use Input with inputmode there.
Six digits, half enteredTwo groups of three with a separator. Filled cells carry data-filled; the empty ones show the "0" placeholder.
.cn-pin-input<div> — The row (flex, gap space.2). role="group" with an aria-label; carries data-size, data-length, data-invalid, data-disabled.
.cn-pin-input__cell<input> — One <input maxlength="1" inputmode="numeric">: square, centered display text in fg-action, hairline, radius lg, shadow-xs. Set data-filled once it holds a character.
.cn-pin-input__separator<span> · optional — A muted "-" between the two halves of a 6-digit code. aria-hidden.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
length
46
4 = short codes (app confirmations, kiosk PINs), rendered as one group with a wider gap; 6 = the common SMS / authenticator code, rendered as two groups of three with a separator between them.
States
state
selector
meaning
focus
.cn-pin-input__cell:focus
On the __cell (not the root): the focused cell shows the action border and the ring. Styled in extraCss because it lives on a child. (native :focus on a cell)
filled
.cn-pin-input__cell[data-filled]
On the __cell: the cell holds a character; the border strengthens so the progress is visible at a glance. Styled in extraCss. (data-filled on the cell (set it whenever value is not empty))
invalid
[data-invalid], &:has([aria-invalid="true"])
Wrong or expired code: every cell takes the danger border, the focused cell the danger ring. Pair with a message under the row. (aria-invalid="true" on the cells, or data-invalid on the root)
disabled
[data-disabled], &:has(input:disabled)
While the code is being verified or after too many attempts: grey cells, disabled text, cursor not-allowed. (disabled on the cells)
Rules
One character per cell, numeric keyboard on mobile (inputmode="numeric", pattern="[0-9]*"), and autocomplete="one-time-code" on the first cell so the OS can offer the SMS code.
Typing a digit moves focus to the next cell; Backspace on an empty cell moves back and clears the previous one; pasting a full code fills every cell at once.
Submit automatically when the last cell is filled, and show the disabled state while verifying; never make the user press a button after typing six digits.
Six digits are displayed as 3 + 3 with a separator; four digits as one group. Do not use other lengths or mix sizes in one form.
Mark filled cells with data-filled so the border tells the progress without reading the digits.
On error, keep the digits, set the invalid state and put a plain sentence under the row ("That code has expired. We sent a new one."). Clear the error on the next keystroke.
Sizes: md on auth screens; sm inside dialogs and settings panels; lg only on a dedicated verification page.
The digit is painted in the action color; the placeholder "0" in the placeholder color. Never dim the typed digit.
Accessibility
The root is role="group" with aria-label ("Verification code"); each cell has aria-label "Digit n of 6" so the position is announced.
Cells are native inputs: focus, disabled and invalid come from the platform. Do not rebuild them with contenteditable divs.
When invalid, set aria-invalid="true" on the cells and aria-describedby on the group (or the cells) pointing at the error message id.
The separator is aria-hidden; it has no meaning beyond grouping.
Focus ring appears on the focused cell only; moving focus programmatically must keep the ring visible.
Forms
RichTextEditor .cn-rich-text-editoredit
A formatted-text field: the Input frame (control border, radius card, shadow-xs, focus ring on :focus-within) with a wrapping toolbar of 32px ghost tool buttons separated into groups by hairlines, a block-type Select, a contenteditable area with a placeholder and prose styles, and an optional footer with a character count and actions.
Use: Use where people write more than a note: descriptions, announcements, email bodies, comments with formatting. Keep the toolbar to what the destination can render. For plain multi-line text use Textarea; for code use Code; for read-only formatted text use Prose.
Default with contentComponent previewBlock-type select, inline styles, headings, lists, link and image. The bulleted-list tool is pressed because the caret sits in the list.
Release notes — September
This month we shipped the new reporting workspace and a faster search index. Highlights:
Custom dashboards for every workspace
Search results in under 200 ms
Exports to CSV and Google Sheets
Read the full changelog or reply to this note with questions.
Invalid and disabledComponent previewdata-invalid draws the danger border; data-disabled greys the frame and makes the tools inert.
Thanks for the update, looping in the finance team so they can confirm the invoice dates before we publish the notes to every workspace and send the customer digest that goes out on Friday morning.
This announcement was published on Sep 9 and can no longer be edited.
HTML
<div style="display:flex;flex-direction:column;gap:var(--cn-space-4);width:100%;max-width:640px"><div class="cn-rich-text-editor" data-variant="with-footer" data-size="sm" data-invalid style="width:100%;max-width:640px"><div class="cn-rich-text-editor__toolbar" role="toolbar" aria-label="Formatting"><div class="cn-rich-text-editor__tool-group" role="group"><button type="button" class="cn-rich-text-editor__tool" aria-label="Bold" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" font-weight="700">B</text></svg></button><button type="button" class="cn-rich-text-editor__tool" aria-label="Italic" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" font-style="italic" font-weight="500">I</text></svg></button><button type="button" class="cn-rich-text-editor__tool" aria-label="Underline" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" text-decoration="underline" font-weight="500">U</text></svg></button></div><div class="cn-rich-text-editor__tool-group" role="group"><button type="button" class="cn-rich-text-editor__tool" aria-label="Insert link"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 9.5l3-3M7 11.25l-1.25 1.25a2.12 2.12 0 01-3-3L4 8.25M9 4.75l1.25-1.25a2.12 2.12 0 013 3L12 7.75"/></svg></button></div></div><div class="cn-rich-text-editor__area" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Note" data-placeholder="Write something…" aria-invalid="true"><p>Thanks for the update, looping in the finance team so they can confirm the invoice dates before we publish the notes to every workspace and send the customer digest that goes out on Friday morning.</p></div><div class="cn-rich-text-editor__footer"><span class="cn-rich-text-editor__count" aria-live="polite">212 / 200</span></div></div><div class="cn-rich-text-editor" data-variant="minimal" data-size="sm" data-disabled style="width:100%;max-width:640px"><div class="cn-rich-text-editor__toolbar" role="toolbar" aria-label="Formatting"><div class="cn-rich-text-editor__tool-group" role="group"><button type="button" class="cn-rich-text-editor__tool" aria-label="Bold" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" font-weight="700">B</text></svg></button><button type="button" class="cn-rich-text-editor__tool" aria-label="Italic" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" font-style="italic" font-weight="500">I</text></svg></button><button type="button" class="cn-rich-text-editor__tool" aria-label="Underline" aria-pressed="false"><svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" text-decoration="underline" font-weight="500">U</text></svg></button></div><div class="cn-rich-text-editor__tool-group" role="group"><button type="button" class="cn-rich-text-editor__tool" aria-label="Insert link"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 9.5l3-3M7 11.25l-1.25 1.25a2.12 2.12 0 01-3-3L4 8.25M9 4.75l1.25-1.25a2.12 2.12 0 013 3L12 7.75"/></svg></button></div></div><div class="cn-rich-text-editor__area" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Note" data-placeholder="Write something…" contenteditable="false"><p>This announcement was published on Sep 9 and can no longer be edited.</p></div></div></div>
Anatomy, props, states, rules
Anatomy
.cn-rich-text-editor<div> — The frame: bg-surface, control border, radius card, shadow-xs, flex column, overflow hidden. Focus ring on :focus-within; data-invalid and data-disabled for those states.
.cn-rich-text-editor__toolbar<div> — role="toolbar": wrapping row of tool groups and the block-type select, 8px padding, bottom hairline.
.cn-rich-text-editor__tool-group<div> — A cluster of related tools (inline styles, headings, lists, inserts); every group after the first gets a hairline on its left.
.cn-rich-text-editor__tool<button> — 32px ghost icon button (28px in sm). Toggles carry aria-pressed; actions (link, image) do not.
.cn-rich-text-editor__select<div> · optional — Wrapper for a filled sm Select of block types (Paragraph, Heading 1–3, Quote); 160px wide.
.cn-rich-text-editor__area<div> — The contenteditable (role="textbox", aria-multiline): min-height 160px, 16px padding, body-md, prose styles for headings, lists, links and quotes. Shows data-placeholder while empty.
.cn-rich-text-editor__footer<div> · optional — Optional bottom row with a top hairline: character count on the left, actions on the right.
.cn-rich-text-editor__count<span> · optional — Character count in body-xs tabular ("412 / 2,000"); fg-danger when over the limit.
.cn-rich-text-editor__actions<div> · optional — Right-aligned row of sm Buttons (ghost Cancel, primary Save).
Props
prop
values (default outlined)
meaning
variant
defaultminimalwith-footer
default = full toolbar (block type, inline styles, headings, lists, link, image) above a 160px area. minimal = inline styles and link only, no hairline under the toolbar, a 96px area: comments and short descriptions. with-footer = default plus a footer with a character count and Cancel/Save actions, for notes that are saved explicitly.
size
smmd
md = 32px tools, 16px area padding, body-md text (forms and full pages). sm = 28px tools, 12px padding, body-sm text for side panels and comment boxes.
States
state
selector
meaning
focus
:focus-within
Writing or using the toolbar: the border becomes action-colored and the 3px ring appears on the frame. (native :focus-within on the root)
invalid
[data-invalid]
Validation failed (required, over the limit): danger border, danger ring while focused. Pair with a Field error. (data-invalid on the root plus aria-invalid="true" on the area)
disabled
[data-disabled]
Read-only or locked: grey fill, disabled text, tools at 50% and inert, area not editable. (data-disabled on the root plus contenteditable="false" on the area)
toolHover
.cn-rich-text-editor__tool:hover
Pointer over a tool (extraCss): subtle fill, ink icon. (native :hover on .cn-rich-text-editor__tool)
toolPressed
.cn-rich-text-editor__tool[aria-pressed="true"]
A formatting toggle that applies to the selection (extraCss): subtle fill and ink icon at rest. (aria-pressed="true" on .cn-rich-text-editor__tool)
toolFocus
.cn-rich-text-editor__tool:focus-visible
Keyboard focus on a tool shows the focus ring (extraCss). (native :focus-visible on .cn-rich-text-editor__tool)
toolDisabled
.cn-rich-text-editor__tool:disabled
A tool that does not apply now (extraCss): 50% opacity, not-allowed. (disabled on .cn-rich-text-editor__tool)
Rules
Offer only what the destination renders: an email body gets inline styles and links; a document gets headings and lists too. Never a toolbar with twenty tools.
Group tools by kind (block type, inline, headings, lists, inserts) with the hairline between groups; keep the same order in every editor of the product.
Toggles (bold, italic, lists) show aria-pressed for the current selection; actions (link, image) open a small Dialog or Popover, never a browser prompt.
The area starts at 160px (96px minimal) and grows with content; never an inner scrollbar unless the editor sits in a fixed-height panel.
Placeholders are an example of the expected content ("Write the release notes…"), not a label; the label lives in the Field.
Show a character count only with a hard limit; over the limit the count turns danger and the frame becomes invalid, but typing is not blocked.
Paste keeps only the formatting the toolbar offers; strip colors, fonts and sizes.
Save explicitly with a footer (with-footer) when the text is a record; autosave with a status line when it is a draft. Never both.
sm inside comment boxes and side panels; md in forms and full pages.
Accessibility
The area is role="textbox" with aria-multiline="true" and an accessible name (a Field label or aria-label); set aria-invalid and aria-describedby with the error when invalid.
The toolbar is role="toolbar" with an aria-label; Tab enters it once, Left/Right move between tools, and the tools are real <button>s with aria-label.
Formatting toggles expose aria-pressed; the block-type Select is a native <select> so it works everywhere.
Keyboard shortcuts (Cmd/Ctrl+B, I, U, K) mirror the toolbar and are listed in a Tooltip on each tool.
Announce the character count with aria-live="polite" and say "over the limit" in the Field error, not only in color.
Forms
TagsInput .cn-tags-inputedit
An Input that holds many values as chips: emails, keywords, labels. One hairline box with the control height as its minimum; chips wrap, and a bare text field grows into the remaining space of the last line.
Use: Free-form lists typed by the user: invite emails, keywords, tags, domains. When the values must come from a known list use MultiSelect; for a single value use Input; for filters you toggle use Tag. Pair with Field for the label and helper ("Press Enter to add").
Invite by emailChips wrap inside the box; the field grows into the rest of the last line.
.cn-tags-input<div> — The bordered box (flex, wrap, align center, gap space.1, padding space.1.5, min-height = control height). Draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Clicking anywhere focuses the field.
.cn-tags-input__tag<span> — One value chip: surface fill, hairline, radius md, 24px tall, body-sm text, followed by its remove button.
.cn-tags-input__tag-remove<button> — 16px button with an × icon at the end of a chip. aria-label "Remove {value}".
.cn-tags-input__field<input> — The bare text input (flex 1, min-width 80px): no border, no outline. Enter or comma commits the text as a chip.
Props
prop
values (default outlined)
meaning
size
smmdlg
Control height and its matching padding, font and icon size.
States
state
selector
meaning
hover
:hover:not([data-disabled])
Border strengthens. (native :hover)
focus
:focus-within
Border becomes the action color and the ring appears around the whole box. (native :focus-within on the box)
invalid
[data-invalid], &:has([aria-invalid="true"])
A value failed validation (bad email, duplicate, too many): danger border, danger ring on focus. Pair with a Field error. (aria-invalid="true" on the input, or data-invalid on the box)
disabled
[data-disabled], &:has(input:disabled)
Grey fill, chips and text in the disabled color, remove buttons inert. (disabled on the input (and on the remove buttons))
removeHover
.cn-tags-input__tag-remove:hover
On the __tag-remove button: subtle fill and default ink. Styled in extraCss because it lives on a child. (native :hover on the remove button)
removeFocus
.cn-tags-input__tag-remove:focus-visible
On the __tag-remove button: its own focus ring. Styled in extraCss. (native :focus-visible on the remove button)
Rules
Enter, comma and blur commit the typed text as a chip; Backspace on an empty field removes the last chip; pasting "a, b, c" creates three chips.
Validate at commit time, not per keystroke: a bad value stays as text in the field with the invalid state and a message that quotes it.
Chips show the value as typed (an email, a keyword), 1–4 words, never truncated; long values wrap to a new line instead.
Clicking anywhere in the box focuses the field; the box, not the field, shows the focus ring.
Set a maximum where one exists (seats, recipients) and say it in the helper text ("Up to 20 emails"); disable the field, not the remove buttons, when it is reached.
Sizes: md in forms; sm in filter bars and dense panels; lg on onboarding screens.
Never mix a TagsInput with a dropdown of suggestions in the same box; that is a MultiSelect.
Width comes from the layout; never set a fixed width, and let the box grow in height as chips wrap.
Accessibility
The field has an accessible name (label for / aria-label); announce additions and removals in a visually hidden live region ("Added maya.chen@lumen.co, 4 recipients").
Each remove button is a real <button> with aria-label "Remove {value}"; after removing, focus returns to the field.
Chips themselves are not focusable; the keyboard interacts through the field (Backspace) and the remove buttons (Tab).
When invalid, aria-invalid="true" on the field and aria-describedby pointing at the message id.
Navigation
Topbar .cn-topbaredit
The application header of the reference: a 64px bar on a hairline with the logo, a row of 32px navigation pills, a search field and the account on the right; optionally a second 64px tier with more pills or underline tabs. Sticky, opaque or translucent.
Use: One per app, at the top of every authenticated page when the app has 3–6 top-level destinations and no Sidebar. Holds the product mark, the primary navigation, global search and the account. Page titles, breadcrumbs and filters do not live here; they belong to the PageHeader below.
Simple headerComponent preview64px bar, 1280px container with 32px padding. Dashboard is current (gray-50 pill); notifications carry the 14px red count.
.cn-topbar<header> — Full-width sticky bar on a surface with a gray-200 hairline below; z-index sticky.
.cn-topbar__inner<div> — The 64px row: a 1280px container with 32px side padding (16 on mobile), items vertically centered, 16px gap.
.cn-topbar__brand<a> — Logo mark + product name (label-lg semibold, 24px tall), links to the home view; 16px before the nav.
.cn-topbar__logo<span> · optional — The 24px brand mark (an image, an inline SVG or the placeholder square).
.cn-topbar__nav<nav> — Primary navigation: a row of items 2px apart. May instead hold an underline Tabs sm whose bar sits on the topbar hairline.
.cn-topbar__item<a> — One nav pill: 32px tall (padding 6 × 8), radius 6, 14px semibold gray-700; gray-50 when hovered or current. Icon-only pills are 36 × 36 (data-icon-only + aria-label).
.cn-topbar__icon<svg> · optional — 20px icon inside an item, fg-subtle; darkens with the label. Decorative.
.cn-topbar__count<span> · optional — Unread count on an icon-only item: a 14px red circle with 10px bold white text, top-right.
.cn-topbar__search<div> · optional — Global search: an Input sm with a ⌘K Kbd, max 280px, in the actions row.
.cn-topbar__actions<div> — Right side, 12px gap: search, icon-only items (settings, notifications), one outline and one primary Button at most, then the account Avatar (a button with aria-haspopup="menu").
.cn-topbar__secondary<div> · optional — Optional second tier under the first: a 64px row on its own hairline holding nav items, or an underline Tabs sm (data-nav="tabs") whose bar sits on the tier's hairline (padding 12 × 32, 0 bottom).
Props
prop
values (default outlined)
meaning
variant
solidtranslucent
solid = opaque surface; the default for app shells. translucent = 85% surface with a 12px backdrop blur so scrolling content shows faintly through; only over a plain canvas that scrolls beneath it, never over images or maps.
align
startcenter
start = brand, then nav, actions pushed right (the reference "simple" header). center = nav centered between a flexible brand block and a flexible actions block (the reference "centered" header).
States
state
selector
meaning
current
.cn-topbar__item[aria-current="page"]
The destination the user is on (on the ITEM): gray-50 fill, gray-800 text, gray-500 icon; gray-100 when also hovered. (aria-current="page" on the item)
hover
.cn-topbar__item:hover
Pointer over an item: gray-50 fill, darker text and icon. (native :hover on the item)
Keyboard focus on an item or the brand: the 4px brand ring. (native :focus-visible)
Rules
Exactly one Topbar per app, 64px per tier, sticky. Secondary navigation is the second tier, a Sidebar or page-level Tabs; never a third bar.
The brand is the 24px mark plus the product name (label-lg semibold) and always links to the home view.
Nav items are 32px pills, 2px apart, 14px semibold; labels are the product's top-level nouns ("Projects", "Reporting"), never verbs. The current one carries aria-current="page".
Actions hold at most: search (280px), two icon-only items, one outline and one primary Button, then the account Avatar. Everything else goes into a Menu behind the account or a ⋯ item.
Global search lives in actions as an Input sm with the ⌘K Kbd and opens the CommandPalette; it is not a page filter.
Page titles, breadcrumbs, filters and tabs of the current page live in the PageHeader below the topbar, never inside it.
translucent only when content scrolls beneath the bar on a plain canvas; on app shells with a Sidebar and over images use solid.
Under 1024px the nav and search hide; use the Sidebar's mobile header (menu button + drawer) instead of squeezing pills.
Accessibility
Root is a <header> (banner landmark); the nav slot is a <nav aria-label="Primary"> of plain links with aria-current="page"; a second tier gets aria-label="Secondary".
Provide a "Skip to content" link as the first focusable element before the topbar, visible on focus.
The brand link has an accessible name (the visible text, or aria-label when it is only a logo image).
Icon-only items carry aria-label; the notification count is read with it ("Notifications, 3 unread") by adding the number to the label or an sr-only span.
The account avatar is a <button aria-haspopup="menu" aria-expanded>; the menu it opens is a Menu.
Because the bar is sticky, set scroll-padding-top on the html element to the total topbar height (64 or 128px).
Navigation
Tabs .cn-tabsedit
A tablist that switches between views of the same object, in several emphasis levels: underline (2px brand bar on a hairline), button-brand (solid action segment), button-gray (gray-50 pill), button-border (white pill in a gray track) and button-minimal (ringed white pill in a gray track); line is a vertical list with a subtle selected fill. Labels are semibold, 14px (sm, 36 / 32px) or 16px (md, 44 / 36px), with optional icons and count badges.
Use: Use to switch between peer views of one thing: sections of a settings page, panes of a record, views of a card. Route-level tabs are links with aria-current="page"; in-page tabs are buttons with aria-selected. Not for sequential steps (Stepper), not for filters (Tag / SegmentedControl), not for more than about eight options (SidebarNav or a Select).
Underline (default)16px medium labels, 36px tall; the selected tab is brand-700 with a 2px brand-600 bar on the gray-200 hairline.
Pill and enclosed (kept for existing markup)pill is the same look as button-border, with token-defined corners and 2px inset spacing. enclosed is the folder tab for editor panes.
<div class="cn-tabs" data-variant="underline" data-size="md" data-orientation="horizontal" data-width="hug" aria-label="Reports"><a href="#" class="cn-tabs__tab" aria-current="page">Customers</a><a href="#" class="cn-tabs__tab">Invoices</a><a href="#" class="cn-tabs__tab">Files</a><a class="cn-tabs__tab" aria-disabled="true" title="Available on the Business plan">Attribution</a></div>
Matrix
variant ↓ / size →
sm
md
underline
pill
enclosed
button-brand
button-gray
button-border
button-minimal
line
Anatomy, props, states, rules
Anatomy
.cn-tabs<div> — The tablist (role="tablist", aria-label). Flex row of tabs (column when data-orientation="vertical"); carries the bottom hairline for underline, or the gray-50 track for button-border / button-minimal / pill.
.cn-tabs__tab<button> — One tab: <button role="tab"> for in-page views, <a aria-current="page"> for routes. Semibold text-quaternary label, radius 6, gap 8; holds text, optional icon, optional count badge.
.cn-tabs__icon<svg> · optional — Optional leading icon, 16px (sm) or 20px (md), fg-subtle. If one tab has an icon, all tabs in the list do. Decorative.
.cn-tabs__count<span> · optional — Optional count after the label: a 22px badge (12px medium) in gray; turns brand-tinted on the selected tab of underline / line / button-brand, stays a white "modern" badge elsewhere. Hidden under 768px. Only for numbers that call for action.
.cn-tabs__panel<div> · optional — The tabpanel (role="tabpanel", aria-labelledby the tab). A sibling AFTER the tablist, never inside it; padding-top separates it from the list.
underline = the page-section default: 2px brand-600 bar under the selected tab on a full-width gray-200 hairline, brand-700 text when selected . button-brand = solid action segment with brand-700 text (settings sub-navigation, filters in a card). button-gray = gray-50 pill with gray-700 text (quiet toolbars). button-border = white pill with a fine outline inside a gray-50 track with a gray-200 ring (view switch in a card header). button-minimal = white pill with a control outline inside a gray-50 track, radius 8 (dense toolbars). line = vertical list with a subtle selected fill and action-colored text (settings side navigation; set data-orientation="vertical"). pill = the older name of button-border, kept for existing markup. enclosed = folder tabs that merge into the panel below (editor panes only).
size
smmd
md = 16px medium labels: 44px button tabs, 36px underline tabs, 32px line tabs; the default for page sections and settings. sm = 14px medium labels: 36px button tabs, 32px underline tabs, 24px line tabs; inside cards, drawers and dense toolbars.
orientation
horizontalvertical
horizontal = a row (every variant except line). vertical = a column, 8px between tabs, labels aligned to the start; use with line, button-brand, button-gray, button-border or button-minimal for a settings side navigation.
width
hugfull
hug = tabs take the width of their labels (default). full = the list spans its container and every tab takes an equal share (mobile, card headers with 2–4 tabs); underline gaps grow from 12 to 16px.
The selected tab (lives on the TAB, not the root). underline: action-colored text and bottom rule; line: action-colored text and subtle fill; button-brand: action fill and on-action text; button-gray: gray-50 fill; button-border: white pill with a fine outline; button-minimal: white pill with a control outline; enclosed: surface fill with an open bottom. (aria-selected="true" on a role="tab" button, or aria-current="page" on a link tab)
hover
.cn-tabs__tab:hover
Pointer over a tab: a subtle surface change; the selection bar stays on the current tab. (native :hover on the tab)
focus
.cn-tabs__tab:focus-visible
Keyboard focus on a tab: the token-defined focus ring around that tab. (native :focus-visible on the tab)
Tab cannot be chosen (feature off, no data yet). 50% opacity, no hover; keep the label so users know the view exists. (disabled or aria-disabled="true" on the tab)
Rules
2–8 tabs. With one there is nothing to switch; beyond eight use SidebarNav or a Select.
Labels are 1–3 words in sentence case, nouns ("My details", "Billing"). Never verbs, never trailing punctuation.
Exactly one tab is selected at all times; the first one by default. Never start with nothing selected.
Heights come only from size: md = 44px button tabs / 36px underline / 32px line; sm = 36 / 32 / 24. Never add padding to change them.
Counts are the 22px badge (count part); only for numbers that ask for action (unread, waiting, failed). Totals belong in the panel.
Icons are all-or-none within one list, leading, 16px at sm and 20px at md, fg-subtle at rest and brand or ink when selected.
underline for page sections and settings pages; button-brand or button-gray for a sub-navigation inside a page; button-border / button-minimal for a view switch inside a card header or toolbar; line (vertical) for a settings side navigation; enclosed only for editor panes with a visible panel border.
Hover uses a quiet fill. The selection rule or solid segment appears only on the current tab.
Tabs switch views of the same object. Different pages are links with aria-current="page"; a sequential flow is a Stepper.
Never nest a tablist inside a tabpanel. A second level of navigation is a SidebarNav or a button-* Tabs inside the card, not tabs under tabs.
Tabs never wrap onto a second line. On narrow screens use data-width="full" with ≤ 4 tabs, or let the tablist scroll horizontally. Focus stays inside the tab so it remains visible in the scroll area.
Accessibility
In-page tabs: root role="tablist" with aria-label, tabs are <button role="tab" aria-selected aria-controls>, panels are role="tabpanel" aria-labelledby, tabindex="0".
Roving tabindex: only the selected tab is in the tab order (tabindex="0"); Left/Right (Up/Down when vertical) move focus, Home/End jump, Enter/Space or automatic activation selects.
Route tabs are plain <a href> with aria-current="page" inside a <nav>; do NOT add role="tab" to links that navigate.
Disabled tabs use aria-disabled="true" (stay perceivable) and a title or Tooltip with the reason.
Selection is conveyed by text color and the bar or pill, never by color alone; the count keeps its number in text and hides only visually on mobile.
Vertical lists set aria-orientation="vertical" on the tablist so arrow-key direction matches.
Navigation
SidebarNav .cn-sidebar-navedit
A grouped navigation rail: a 280px column of 36px link rows (14px semibold, 20px icon, radius 6) grouped with subheadings or dividers, a quiet hover fill and action-colored text on the current destination, with count badges and collapsible children.
Use: Use as the nav list of a Sidebar or on its own for the second level of navigation inside a product area (settings sections, a workspace's views). Items are answers to what the user wants to do, grouped by job, never a mirror of the data model. For 3–6 top-level destinations use the Topbar instead.
Simple listComponent preview36px rows, 14px semibold, 20px icons; Dashboard is current (gray-50), Tasks carries a 22px count badge.HTML
Dividers and a collapsible parentComponent previewA <details> group with a <summary> row: the chevron flips when open; child rows are indented to 40px.HTML
Inset rail, smallComponent previewThe rail is gray-50 with a trailing hairline; the current item uses a surface fill and action-colored text. 32px rows.HTML
.cn-sidebar-nav<nav> — The column: 280px wide, 20 × 16px padding, groups stacked. Has aria-label.
.cn-sidebar-nav__group<div> — A set of related items with role="group" (or a <details> for a collapsible parent); rows 2px apart, 16px above each group after the first.
.cn-sidebar-nav__group-label<div> · optional — Subheading naming the group in 12px semibold fg-subtle, 8px inset: "General", "Workspace". Referenced by the group's aria-labelledby.
.cn-sidebar-nav__item<a> — One destination: a 36px link row (or <summary> for a collapsible parent), padding 8, radius 6, 14px semibold gray-700; holds icon + label + optional count or chevron. data-level="2" indents a child row to 40px.
.cn-sidebar-nav__icon<svg> · optional — 20px leading icon, fg-subtle at rest, one step darker when hovered or current. All items in a group have one, or none do.
.cn-sidebar-nav__label<span> — The destination name, 1–2 words. Fills the row and truncates with an ellipsis.
.cn-sidebar-nav__count<span> · optional — Trailing count in a 22px gray badge (12px medium, gray-50, gray-200 ring), 12px after the label. Only for numbers that need action, never totals.
.cn-sidebar-nav__chevron<svg> · optional — 16px chevron-down at the end of a collapsible parent; flips when the <details> is open.
.cn-sidebar-nav__divider<hr> · optional — 1px gray-200 rule between groups (the "dividers" layout), 8px above and below.
Props
prop
values (default outlined)
meaning
size
smmd
md = 36px rows, 14px semibold, 20px icons; the default. sm = 32px rows (padding 6 × 8), 14px semibold, 16px icons for dense admin rails and secondary panels.
variant
defaultinset
default = transparent on the surface; hovered and current rows get the gray-50 fill (the default). inset = the whole nav is a gray-50 rail with a hairline on its trailing edge, and the current item uses a surface fill and action-colored text.
States
state
selector
meaning
hover
.cn-sidebar-nav__item:hover
Pointer over an item (lives on the ITEM, not the root): gray-50 fill, gray-800 label, gray-500 icon. (native :hover on the item)
current
.cn-sidebar-nav__item[aria-current="page"]
The destination the user is on: gray-50 fill (gray-100 when hovered), ink label, darker icon (inset: surface fill with action-colored text). Exactly one per nav. (aria-current="page" on the item)
focus
.cn-sidebar-nav__item:focus-visible
Keyboard focus on an item: the token-defined focus ring around the row. (native :focus-visible on the item)
disabled
.cn-sidebar-nav__item[aria-disabled="true"]
Destination not available on this plan or not yet set up. 50% opacity, no hover; keep the label so the user knows it exists. (aria-disabled="true" on the item (omit href))
Rules
Max 7 items per group and max 4 groups visible. If the nav needs more, the information architecture is wrong, not the component.
Never one item per database table. Items are jobs and destinations ("Dashboard", "Reporting", "Billing"), grouped by who uses them and when.
Labels are 1–2 words, sentence case, nouns. No verbs ("Manage members" → "Members"), no product jargon.
Rows are 36px (sm 32px) with 8px padding and 2px between them; never change the height with padding.
Icons are all-or-none within a group, 20px (16px at sm), fg-subtle; never emoji, never colored.
Counts use the 22px badge and only for what needs action (unread, pending approvals, failed runs), never totals; hide the count at 0.
Exactly one item carries aria-current="page" at any time, the deepest matching route; a collapsible parent stays open while a child is current.
Width is fixed at 280px; the content never widens it. Long labels truncate with an ellipsis and get a title attribute.
Two levels at most: a collapsible parent and its children. Deeper sections are Tabs on the page.
The first group may go unlabeled (the home set); later groups get a subheading or a divider, not both.
Accessibility
Root is <nav aria-label="…"> naming the area ("Main", "Settings"); each group is role="group" with aria-labelledby pointing at its subheading.
Items are real <a href> links; aria-current="page" marks the current destination and is the only selection state.
Collapsible parents are <details>/<summary> so they work without JS; the chevron is decorative.
Disabled items use aria-disabled="true", drop the href, and explain why in a title or Tooltip; they stay in the DOM so the structure is stable.
Counts are read as part of the link text; add sr-only context when the number is ambiguous ("10 open tasks").
Keyboard: Tab moves through items in order; no roving tabindex or arrow keys (this is a list of links, not a menu).
Navigation
Breadcrumb .cn-breadcrumbedit
The path to the current page in the reference's three trail types: 14px medium links separated by 16px gray-300 chevrons (or slashes), ending in the current page one step darker; plain text (20px), text on a hairline (36px) or 28px button chips. Sits above the page title.
Use: Use on pages three or more levels deep (Settings → Team → Roles) so the user can go up one level with one click. Not for top-level pages, not for wizard progress (Stepper), never inside the Topbar.
Text (default)Ancestors gray-600, the current page one step darker; 16px gray-300 chevrons.HTML
.cn-breadcrumb<nav> — The landmark: <nav aria-label="Breadcrumb">, 14px medium, fg-muted.
.cn-breadcrumb__list<ol> — Ordered list of levels, root first. Flex row, 8px gaps, wraps on narrow screens.
.cn-breadcrumb__item<li> — One level: an optional separator followed by a link, the ellipsis button, or the current page.
.cn-breadcrumb__link<a> — An ancestor page, fg-muted; ink on hover (gray-50 chip in the button type). Truncates at 24 characters.
.cn-breadcrumb__icon<svg> · optional — Optional 20px home icon as the first link (with aria-label="Home"), fg-subtle.
.cn-breadcrumb__separator<svg> — 16px gray-300 chevron (or a "/" span) before every item except the first. aria-hidden.
.cn-breadcrumb__ellipsis<button> · optional — The "…" that stands in for collapsed middle levels; opens a Menu listing them.
.cn-breadcrumb__current<span> — The page the user is on: one step darker than the links, medium, aria-current="page". Not a link.
Props
prop
values (default outlined)
meaning
variant
textlinebutton
text = plain 20px trail (page headers, the default). line = the trail sits on a full-width gray-200 hairline, 36px tall (above a page title without its own divider). button = each level is a 28px chip (radius 6, padding 4 × 8); the current one is filled gray-50 (record headers, drawers).
divider
chevronslash
chevron = 16px gray-300 chevron-right (default). slash = a "/" in gray-300 (denser, developer-facing paths).
size
smmd
md = 14px medium (text-sm); the page-header default. sm = 12px medium for drawers, dialogs and card headers.
States
state
selector
meaning
hover
.cn-breadcrumb__link:hover
Pointer over an ancestor link (on the LINK, not the root): ink text; in the button type also a gray-50 chip. (native :hover on the link)
Keyboard focus on a link or the ellipsis: the 4px brand ring. (native :focus-visible)
current
[aria-current="page"]
The last item: one step darker, medium, no hover, not a link (button type: gray-50 chip). (aria-current="page" on the current span)
Rules
Max 4 visible levels. Deeper paths collapse the middle into an ellipsis that opens a Menu of the hidden levels.
Labels are the exact page titles of the ancestors (same text as their h1), not shortened synonyms.
The last item is the current page: darker, not a link, aria-current="page". Never end with a separator.
Never show a one-level breadcrumb ("Home" alone). Below three levels, skip the breadcrumb entirely.
Place it in the page header directly above the title, 8px apart. Never in the Topbar and never at the bottom of the page.
Separators are the 16px gray-300 chevron, or the slash for path-like trails; never arrows, dots or both in one app.
text in page headers; line when the header has no divider of its own; button in record headers and drawers where the trail is also a filter.
sm only inside drawers, dialogs and card headers; md everywhere else.
Truncate long names with an ellipsis and provide the full name in a title; never wrap a single label across lines.
Accessibility
Root is <nav aria-label="Breadcrumb"> containing an <ol>; order conveys hierarchy.
The current page has aria-current="page" and is not a link (or is a link only if it reloads the same page).
Separators are aria-hidden="true"; the chevron or slash is decoration, the list order is the semantics.
A home icon link carries aria-label="Home"; the icon itself is aria-hidden.
The ellipsis is a <button aria-label="Show N hidden levels" aria-haspopup="menu" aria-expanded>, not plain text.
Truncated labels keep their full text in a title attribute and in the accessible name.
Navigation
Pagination .cn-paginationedit
Page controls of the reference: 36px page numbers (14px medium, gray-50 when current) with ellipses, secondary-style Previous / Next buttons at the ends on a hairline; a minimal "Page 1 of 10" center; a segmented button group; dots and lines for carousels.
Use: Use under a table or list whose total is known and larger than one page (page-default under page-level tables, minimal or button-group in a table card footer). Use simple or minimal when the total is unknown or the space is narrow. dots and line are for carousels and onboarding slides, never for data.
Page defaultPrevious and Next at the ends (secondary-style, 36px), numbers centered 2px apart on a hairline with 20px of padding. Page 1 is current in gray-50.
Dots and lines (carousels)10px dots (8px at sm) and 40 × 8 lines, gray-200 at rest and brand-600 when current; data-framed puts them on a white ringed pill.
.cn-pagination<nav> — <nav aria-label="Pagination">: a flex row, space-between, 12px gap; page-default and minimal add the hairline above with 20px of padding.
.cn-pagination__summary<span> · optional — The position in words, 14px medium gray-700 with tabular numbers: "Page 1 of 10" (minimal) or "1–10 of 240" (default).
.cn-pagination__pages<div> · optional — The centered row of page items, 2px apart (0 in button-group, 8 for dots and lines).
.cn-pagination__prev<button> — Previous: a 36px secondary-style button (gray-300 ring, shadow-xs) with a 20px arrow and the word "Previous"; data-icon-only makes it a 36px square. Disabled on the first page, never hidden.
.cn-pagination__item<button> — One page number, 36 × 36, 14px medium gray-500, radius 8 (or full); gray-50 with gray-700 text when hovered or current (aria-current="page").
.cn-pagination__ellipsis<span> · optional — The "…" standing in for skipped pages, 36 × 36, gray-600, aria-hidden.
.cn-pagination__next<button> — Next: the mirror of prev. Disabled on the last page.
.cn-pagination__icon<svg> — 20px arrow or chevron inside prev / next. Decorative.
.cn-pagination__dot<button> · optional — An 8px gray-200 dot (dots variant); brand-600 when current. aria-label "Slide N".
.cn-pagination__line<button> · optional — A 40 × 6 gray-200 bar (line variant); brand-600 when current.
default = summary, prev, page items and next in one row (inline under a table). simple = summary + prev / next only, for unknown totals and narrow layouts. page-default = the reference page pagination: Previous at the left, numbers centered, Next at the right, on a hairline with 20px padding. minimal = Previous, "Page 1 of 10" and Next, same hairline (table card footers). button-group = the page items as one 36px segmented control with shared rings. dots = 8px dots for carousels. line = 40 × 6 bars for onboarding steps.
size
smmd
md = 36px items and buttons (the reference). sm = 32px items and buttons for dense card footers and drawers.
shape
squareround
square = radius 8 on page items (default). round = fully rounded page items (the reference rounded={true}).
align
leftcenterright
Where the pages or the summary sit in minimal, button-group, dots and line: left, center (default) or right. page-default is always Previous | pages | Next.
The page being shown (on the ITEM, not the root): gray-50 fill with gray-700 text; dots and lines turn brand-600. (aria-current="page" on the page item, aria-current="true" on a dot or line)
A button at a bound (first or last page): 50% opacity, no hover; stays visible so the layout never jumps. (disabled (or aria-disabled="true" on links))
Rules
At most seven page slots: first, last, the current page with one neighbour on each side, and ellipses for the gaps.
Page items are 36 × 36 (sm 32), 14px medium, 2px apart; the current page is gray-50 with darker text, the same look as hover. Never an inverse fill.
Previous and Next are always present and always the secondary-button look; at a bound they are disabled, never removed, so the layout does not shift. Under 768px they become 36px icon-only buttons.
page-default under page-level tables and lists; minimal or button-group in a table card footer (12 × 24px padding); simple for cursor APIs, unknown totals and drawers.
The minimal summary reads "Page N of M" in 14px medium; the default summary reads "1–10 of 240". Never both.
dots and line are for carousels and onboarding slides only (aria-current="true" on the current one); never for tables.
The page-size selector ("10 per page") belongs to the table toolbar or the advanced card footer, not next to the page items.
sm only inside cards and drawers; md under page-level tables.
When the page number is part of the URL, render every control as a link; otherwise as buttons. Never mix.
Accessibility
Root is <nav aria-label="Pagination">; when there are several on a page, name them ("Customers pagination").
The current page carries aria-current="page" and is announced as such; the gray fill is the visual cue, the attribute is the semantic one.
Prev / Next have aria-label="Previous page" / "Next page" even when the word is visible; the arrows are aria-hidden.
Ellipses are aria-hidden; screen readers hear the page numbers, not "dot dot dot".
Disabled bounds use the disabled attribute on buttons or aria-disabled="true" on links (keep the href out of the tab order).
Announce page changes: put the summary in an aria-live="polite" region when the table updates in place.
Dots and lines are buttons with aria-label "Slide N" / "Step N" and aria-current="true" on the active one.
Navigation
Stepper .cn-stepperedit
Progress through a sequence in the reference's four marker styles: numbered circles, step icons (disc with a dot, brand when current, check when complete), minimal dots, or a 4px bar over the text; 24 / 32px, joined by 2px connectors, horizontal or vertical, with a semibold title and supporting text per step.
Use: Use above a multi-step form or wizard (onboarding, import, checkout) with 3–6 steps that happen in order, or as a vertical rail beside a long form. Not for switching between peer views (Tabs), not for percentage progress (Progress) and not for a timeline of past events (Timeline).
Icon with text (default md)The reference step icon: gray-50 disc with a gray-300 dot; current is brand-600 with a white dot and a 4px halo, complete is brand with a white check. 2px connectors turn brand after a complete step.
.cn-stepper<ol> — Ordered list of steps with aria-label. Flex row (horizontal) or column (vertical), 16px gaps.
.cn-stepper__step<li> — One step: indicator + content + connector. data-state="complete|current|error" (omit for upcoming); aria-current="step" on the current one.
.cn-stepper__indicator<span> — The marker: a 24 / 32px circle (number or icon), an 8 / 10px dot, or a 4px bar. Upcoming: gray-50 with a gray-300 ring or dot; current: brand-600 with a 4px brand-100 halo; complete: brand-600 with a white check; error: red.
.cn-stepper__dot<span> · optional — The inner dot of the icon variant: 8 / 10px, gray-300 when upcoming, white when current.
.cn-stepper__check<svg> · optional — 12 / 16px white check mark inside a complete indicator (number and icon variants).
.cn-stepper__content<span> — Column holding label and description, 2px apart, aligned to the indicator's center (under the bar in the line variant).
.cn-stepper__label<span> — Step title, 14px semibold: 1–3 words ("Company details"). Gray-700 when upcoming or complete, brand-700 when current.
.cn-stepper__description<span> · optional — Optional 14px supporting text under the title, fg-muted: "Website and location". Carries the message in the error state.
.cn-stepper__connector<span> — 2px gray-200 line from this step to the next; fills the gap horizontally or runs down the indicator's axis vertically. Hidden on the last step and in the line variant; brand-600 after a complete step.
Props
prop
values (default outlined)
meaning
variant
numbericondotline
number = the step number in a ringed circle (forms with many steps). icon = the reference step icon: a gray-50 disc with a gray-300 dot that turns brand with a white dot when current and a check when complete (onboarding, wizards). dot = minimal 8 / 10px dots joined by connectors, labels optional (compact progress under a dialog title). line = a 4px bar above each step's text, no connectors (the reference "text with line").
orientation
horizontalvertical
horizontal = a row across the top of a wizard, steps share the width and connectors fill the gaps; up to 5 steps. vertical = a column in a side rail or next to a long form (the reference 400px column); the connector runs down from each indicator, so descriptions can be longer.
size
smmd
md = 32px circles (10px dots, 16px check), 14px titles; the default above a page-level form. sm = 24px circles (8px dots, 12px check), 14px titles; inside dialogs, drawers and cards.
States
state
selector
meaning
complete
.cn-stepper__step[data-state="complete"]
Done (on the STEP, not the root): indicator filled brand-600 with a white check (bar or dot turns brand); the connector after it turns brand-600; title stays gray-700. (data-state="complete" on the li; put the check svg (and sr-only "Completed:") inside the indicator instead of the number)
The step the user is on: brand-600 indicator with a white dot (number variant: 2px brand ring and brand number) and a 4px brand-100 halo; title in brand-700. Exactly one. (data-state="current" and aria-current="step" on the li)
error
.cn-stepper__step[data-state="error"]
A visited step that failed validation: red-500 ring on a red-50 disc, red title; the description says what to fix. (data-state="error" on the li, with the problem in the description)
upcoming
.cn-stepper__step:not([data-state])
Not reached yet: gray-50 disc with a gray-300 ring or dot, gray-700 title. The default; no attribute needed. (no data-state)
Rules
3–6 steps. Two steps are a single form with a confirm; more than six means the flow needs to be split or some steps merged.
Titles are short and specific to the task. Labeled horizontal steps stack below 768px so titles and descriptions stay readable; unlabeled progress dots remain a row.
Exactly one current step at all times; everything before it is complete (or error), everything after it upcoming.
Complete steps may be clickable (wrap the label in a Link, or make the step a button) to go back; upcoming steps are never clickable.
Descriptions are optional and short (≤ 5 words); in the error state the description is the message that says what to fix.
Error only on a visited step and only with a message; never mark an upcoming step as error.
icon or number for wizards above a form; dot for compact progress under a dialog title; line when the steps are also section titles of a long page.
Horizontal for up to 5 steps above a wizard; vertical (max 480px wide) when there are 5–6 steps, when descriptions are needed, or in a side rail beside a long form.
Indicators are 32px (sm 24) with 2px connectors; the check appears only on complete steps. No custom icons, letters or emoji in the circles.
The stepper shows position, it does not save. Moving between steps is done by the form's Back / Continue buttons.
Accessibility
Root is an <ol aria-label="…"> so the order is announced ("step 2 of 4"); each step is an <li>.
The current step carries aria-current="step" on the li in addition to data-state.
Status is not color alone: complete indicators contain the check plus sr-only "Completed:", error indicators keep the number plus sr-only ", needs attention:".
Connectors and decorative indicators are aria-hidden; the label text is the accessible name of the step. Dot steps without labels need an sr-only label each.
If steps are clickable, they are links or buttons with the step name as text, and upcoming steps are not focusable.
Navigation
HeaderNavigation .cn-header-navigationedit
Marketing website header: 80px bar with logo, text links (with optional mega menu), and Log in / Sign up actions; collapses to a hamburger on mobile. "header navigation".
Use: Top of every marketing page (landing, pricing, about, blog). Inside the product use Topbar or Sidebar instead.
default = white with bottom hairline; transparent = no background/border (over a hero); with-menu = shows the mega menu panel open under the bar (static preview).
theme
lightdark
dark = gray-950 bar with light text (dark landing pages).
States
state
selector
meaning
hover
.cn-header-navigation__item:hover
Link darkens. (native)
current
.cn-header-navigation__item[aria-current="page"]
Current section is fg-default. (aria-current="page")
open
[data-state="open"]
Mega menu visible. (data-state="open" on the root)
Rules
80px tall on desktop, 64 on mobile; 1280px container with 32px padding; logo left, links after a 40px gap, actions right.
Links: 16px semibold gray; ≤ 5 items; dropdown items show a chevron and open a mega menu with icon + title + one-line description rows.
Actions: exactly "Log in" (tertiary/ghost, lg) and one primary CTA (lg). Never two filled buttons.
Below 1024px, links and actions hide behind the hamburger; the mobile menu lists the same links stacked, then the two actions full width.
Transparent variant only over a hero whose background guarantees contrast.
Accessibility
<header> + <nav aria-label="Main">; dropdown triggers use aria-expanded; the mega menu is reachable by keyboard and closes with Escape.
The hamburger is a labelled button.
Navigation
MobileHeader .cn-mobile-headeredit
The 64px top bar that replaces the Sidebar on small screens: logo on the left, a 40px hamburger on the right, a bottom hairline; plus the 296px drawer it opens with a logo row, 48px navigation items, the support and settings group and the account card at the bottom. The drawer is positioned under the bar (absolute), never fixed.
Use: Use as the app header below the lg breakpoint, paired with the Sidebar above it. Not for marketing sites (HeaderNavigation has its own mobile menu) and not as a page header inside content (PageHeader).
SimpleComponent previewThe resting bar: logo left, hamburger right, hairline below. The drawer is in the DOM but hidden.
With the drawer openComponent previewThe toggle becomes ×, the scrim dims the page and the 296px drawer slides in under the bar: logo row, 48px items, Support/Settings and the account card.
.cn-mobile-header<header> — The 64px bar: flex, space-between, 16px horizontal padding, bg-surface, bottom hairline; relative so the drawer anchors to it.
.cn-mobile-header__logo<a> — Link to home holding the mark and the wordmark. aria-label "Lumen home".
.cn-mobile-header__mark<span> — 28px action-colored square mark (replace with the real logo).
.cn-mobile-header__brand<span> — The product name, label-md semibold.
.cn-mobile-header__actions<div> · optional — Optional cluster before the toggle (search, notifications) as ghost IconButtons.
.cn-mobile-header__toggle<button> — 40px hamburger button with a 24px icon; aria-expanded and aria-controls point at the drawer; shows an × when open.
.cn-mobile-header__scrim<div> · optional — The overlay under the bar that dims the page while the drawer is open (bg-overlay). Hidden when closed.
.cn-mobile-header__drawer<nav> · optional — The 296px panel under the bar: bg-surface, right hairline, shadow-xl, 16px padding, flex column. aria-label "Main". Hidden when closed.
.cn-mobile-header__drawer-header<div> · optional — First row of the drawer: the logo again and a CloseButton.
.cn-mobile-header__nav<div> · optional — Main list of nav items, 4px apart, fills the drawer.
.cn-mobile-header__item<a> · optional — 48px navigation link: 24px icon, 16px semibold label, optional count. aria-current="page" on the current one.
.cn-mobile-header__icon<svg> · optional — 24px icon in fg-subtle before the label.
.cn-mobile-header__label<span> · optional — The item text, truncated.
simple = the bar only; the drawer and scrim are hidden (the resting state). with-drawer-open = the drawer and scrim are shown under the bar and the toggle shows ×; the app switches to this state when the toggle is pressed.
States
state
selector
meaning
expanded
.cn-mobile-header__toggle[aria-expanded="true"]
The toggle while the drawer is open (on the toggle; extraCss): subtle fill and ink icon. (aria-expanded="true" on .cn-mobile-header__toggle)
current
.cn-mobile-header__item[aria-current="page"]
The current destination (on the item; extraCss): subtle fill, ink text. (aria-current="page" on .cn-mobile-header__item)
Keyboard focus shows the ring on links and buttons (extraCss). (native :focus-visible)
Rules
Show the MobileHeader below the lg breakpoint and the Sidebar above it; never both at once.
The bar is 64px with the logo on the left and the hamburger on the right; at most two icon actions between them.
The drawer mirrors the Sidebar exactly: same items, same order, same account card. Do not add mobile-only destinations.
Items are 48px so they are comfortable to tap; the current one has the subtle fill and aria-current="page".
Opening the drawer swaps the hamburger for an ×, adds the scrim and locks page scroll; closing restores focus to the toggle.
Counts use the badge part; never red dots for ordinary counts.
In the app, position the drawer as a fixed layer in the app shell; the component itself only styles the panel under the bar.
Escape and a tap on the scrim close the drawer; swiping left may close it but is never the only way.
Accessibility
The toggle is a <button> with aria-label ("Open menu" / "Close menu"), aria-expanded and aria-controls pointing at the drawer id.
The drawer is a <nav aria-label="Main">; the current item carries aria-current="page". While open, focus moves into the drawer and stays there until it closes.
The scrim is aria-hidden; the CloseButton inside the drawer duplicates the toggle for screen-reader users who are inside the panel.
Initials avatars carry role="img" with the full name; the log-out button has an aria-label.
Touch targets are at least 40px (toggle) and 48px (items); the logo link has an aria-label because the mark alone has no name.
Navigation
Sidebar .cn-sidebaredit
The application sidebar family of the system: a 280px column with logo, search, navigation items matching SidebarNav (36px, label-md, 20px icons), a bottom group and an account card; slim 68px rail; dual-tier rail + panel; sections with subheadings; dark theme.
Use: Primary navigation of an application with more than 5 destinations. For 3–5 destinations prefer the Topbar with tabs. Groups: main destinations at top, support/settings at the bottom, account card last. Never one item per database table; items are user tasks.
.cn-sidebar__account-action<button> — 36px utility button (log out / ⋯).
.cn-sidebar__rail<div> · optional — The 68px icon-only column of the dual-tier variant.
.cn-sidebar__panel<div> · optional — The 256px second-tier panel of the dual-tier variant.
.cn-sidebar__rail-item<a> · optional — 48px square icon button with a 20px icon in a rail.
Props
prop
values (default outlined)
meaning
variant
simpleslimdual-tiersectionsdividers
simple = one flat list (280px); slim = 68px icon rail with tooltips; dual-tier = 68px rail + 256px panel for the active area; sections = groups with subheadings; dividers = groups separated by hairlines.
theme
lightdarkbrand
light = white; dark = gray-950 surface with gray-800 borders; brand = brand-700 surface (marketing-style app).
States
state
selector
meaning
current
.cn-sidebar__item[aria-current="page"]
The active destination: subtle fill with action-colored text and icon. (aria-current="page" on the item)
hover
.cn-sidebar__item:hover
Subtle fill. (native)
focus
.cn-sidebar__item:focus-visible
Focus ring. (native)
Rules
Width 280px, matching SidebarNav. Slim rail 68px. Dual tier 68 + 256. The sidebar never scrolls the page; its own nav scrolls.
Items match SidebarNav: 36px tall with a 20px icon, 8px gap and horizontal padding, label-md typography, and 2px between rows. The current item uses action-colored text and a subtle surface; hover uses a quiet neutral fill.
Order: logo, optional search, main items (≤ 7), sections, then the footer with Support/Settings and the account card.
Counts use the badge part; never red dots for ordinary counts.
On tablets collapse to the slim rail; on mobile replace with the MobileHeader drawer.
Accessibility
<nav aria-label="Main">; the current item carries aria-current="page".
Slim rail items are icon-only and need aria-label (and a Tooltip on hover).
The account log-out control is a labelled button.
Data display
Badge .cn-badgeedit
Small status label with compact corners (or an optional pill): 12/14px medium text, a 1px ring one step darker than the fill, optional dot, icon, avatar or close button. The tone carries meaning, the variant carries emphasis.
Use: Statuses, categories and counts next to a title, in a table cell, inside a tab or a nav item. Not for actions (use Button) and not for selectable or removable filters (use Tag) — the close button here only dismisses a label.
Colors (pill, soft)Order: gray, brand, error, warning, success, then the decorative hues.
Meaning. neutral (gray) for categories; accent (brand) for brand/AI; success/warning/danger (error) for state; info = blue. The extra hues (gray-blue, blue-light, blue, indigo, purple, pink, orange) are the system's decorative palette for labels and tags: use them only for user-chosen categories, never for state.
variant
softoutlinesolidmodern
soft = tinted fill with a ring one step darker (the system pill-color / color); modern = white fill, gray ring and shadow-xs, only the dot/icon takes the tone color; outline = ring only, transparent fill; solid = filled, for one emphasized status per row at most.
shape
pillsquare
square = compact token-defined corners (default); pill = fully rounded, for an explicitly round visual family.
size
smmdlg
sm 22px tall with 12px text (tables, tabs, nav items); md 24px with 14px text (default); lg 28px with 14px text (page headers, cards).
Rules
Text is 1–2 words in sentence case, medium weight, never uppercase.
One solid badge per row at most. Everything else soft, modern or outline.
Tone is meaning, not decoration: success = done/healthy, warning = needs attention, danger = failed/blocked, info = new/informational, accent = brand or AI-generated, neutral = category. The decorative hues are for user-defined labels only.
Modern badges keep gray text whatever the tone; only the dot or icon takes the color. Use them on tinted backgrounds where a colored fill would clash.
sm inside tables, tabs and nav items; md next to titles; lg in page headers and cards. Match the size of the text next to it.
Never make the badge itself clickable. If it filters, it is a Tag; if it acts, it is a Button. The × only dismisses a label.
Accessibility
The text must carry the meaning; the dot, icon and tone are reinforcement. "Working" not just a green dot.
The close button needs aria-label "Remove {text}" and is the only focusable thing in a badge.
Live states with a dot may also use aria-live on the containing region, not on the badge.
Avatar images inside badges are decorative (alt="") because the text names the person.
Data display
Tag .cn-tagedit
The reference tag: a white chip with a gray-300 ring, radius 6 and 14px medium text (sm 24 · md 28 · lg 32px) that you can toggle or remove; leading checkbox, avatar or dot, trailing count chip or close ×. Unlike Badge it responds to focus and selection.
Use: Filter bars (toggle or checkbox tags), token fields and applied-filter rows (removable tags), multi-select values, keyword lists with counts. Not for statuses or categories that only inform (Badge), and not for actions with a verb (Button).
.cn-tag<span> — A <span> for a value; a <button aria-pressed> (or role="checkbox" aria-checked with the checkbox part) when selectable. White, gray-300 ring, radius 6.
.cn-tag__checkbox<span> · optional — Leading 14 / 16 / 18px checkbox (radius 4, gray-300 ring); brand-600 with a white check when the tag is checked.
.cn-tag__check<svg> · optional — The 10 / 12 / 14px check inside the checkbox; hidden until checked.
.cn-tag__avatar<img> · optional — Leading 16px round avatar with the 0.5px contrast ring.
.cn-tag__dot<span> · optional — Leading 8px status dot, green-500 by default (override the color inline for other states).
.cn-tag__icon<svg> · optional — Optional 12px leading icon (a flag, a spark for suggested values). Decorative.
.cn-tag__label<span> — The value text, sentence case, 1–3 words. Never truncated.
.cn-tag__count<span> · optional — Trailing count chip: radius 3, gray-100, 12px medium (14px at lg), padding 0 × 4–6.
.cn-tag__remove<button> · optional — Trailing close ×: radius 3, gray-400, 14 / 16 / 20px box with a 10 / 12 / 14px icon; gray-50 on hover. Its own focus target with aria-label "Remove {label}".
Props
prop
values (default outlined)
meaning
tone
neutralaccent
neutral = user-chosen values and filters (the reference); accent = values suggested by the assistant or tied to the brand (use sparingly, a few per view).
variant
outlinesoft
outline = white with the gray-300 ring (the reference tag, on surfaces); soft = gray-50 fill with no ring (on the canvas, inside inputs, in dense rows).
size
smmdlg
sm 24px with 12px text (table cells, dense filter rows); md 28px with 14px text (default); lg 32px with 14px text (filter bars next to 32px controls).
States
state
selector
meaning
hover
:hover:not(:disabled):not([aria-disabled="true"])
Pointer over the element (mouse only; never the only cue). (native :hover)
Toggled on. A pressed chip gets the brand tint; a checkbox tag fills its checkbox brand-600 with the white check. (aria-pressed="true" on a button tag, aria-checked="true" on a role="checkbox" tag, or data-selected)
focus
:focus-visible
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
disabled
:disabled, &[aria-disabled="true"]
Not interactive. Reduced opacity, no hover, cursor not-allowed. (disabled attribute (or aria-disabled="true"))
Rules
Tag is interactive or removable; Badge is not. If it cannot be toggled or removed, it is a Badge.
Labels are nouns in sentence case, 1–3 words, never uppercase and never truncated.
Heights come only from size: 24 / 28 / 32px with 8 / 9 / 10px side padding; the leading and trailing parts pull in by their margins, never by extra padding.
Selectable tags are <button aria-pressed> (brand tint) or role="checkbox" with the checkbox part (white tag, brand checkbox); removable tags are a <span> with a remove <button> inside. A tag is never both selectable and removable.
The remove button is always last, always has aria-label "Remove {label}", and is the only element with hover feedback inside a span tag.
One leading part at most (checkbox, avatar, dot or icon) and one trailing part at most (count or ×).
outline on surfaces (filter bars over white); soft on the canvas, inside inputs and in dense rows where rings would pile up.
accent only for assistant-suggested or brand-linked values, and only a few per view. Never as decoration.
Rows of tags wrap with an 8px gap and align to the start; do not right-align or justify them. One size per row.
Accessibility
Selectable: <button type="button" aria-pressed="true|false">, or role="checkbox" aria-checked with the checkbox part. Toggle the aria attribute, not only the data attribute.
Removable: the remove button announces "Remove {label}"; after removing, move focus to the next tag or back to the input.
Both the tag and its remove button are separate Tab stops with their own :focus-visible ring.
Avatars inside tags are decorative (alt=""); the label names the person. A dot needs its meaning in the label ("Online").
Disabled tags stay readable (opacity only). Use disabled on buttons and aria-disabled="true" on span tags.
Data display
Counter .cn-counteredit
A small round pill with a number: the reference's 22px count badge (12px medium, tinted with a ring) after tabs and nav items, or the 14px red disc on a notification icon. Tabular figures, capped at "99+". Numbers only.
Use: After a label that it counts: a tab, a nav item, a section title, a filter; or on the corner of an icon button for unread notifications. Not for words (Badge), not for large KPIs (Stat), never as a button.
Soft (default)22px, gray-50 with a gray-200 ring, 12px medium gray-700.
In a nav item, a tab and on an iconThe counter follows the label it counts; only the "needs you" count is action-tinted. data-anchored pins the xs disc to the icon corner.
.cn-counter<span> — The pill. Contains only the number (or "99+").
Props
prop
values (default outlined)
meaning
tone
neutralaccentdangeraction
Urgency, not decoration. neutral (gray) = totals and result counts; action (brand) = things waiting for the user, and the selected tab's count; danger (red) = failures, overdue items and unread notifications; accent = items produced by the assistant.
variant
solidsoft
soft = tinted fill with a 1px ring one step darker (the reference badge); solid = filled, at most one per row or nav to mark the count that matters (and always for the xs notification disc).
size
xssmmd
md 22px tall with 12px medium text (the badge next to tabs and nav items); sm 18px inside buttons and dense rows; xs 14px with 10px bold text, the notification disc on an icon corner.
Rules
Numbers only. A word ("New", "Beta", "Live") is a Badge.
Cap at "99+". Never show three or more digits.
Hide the counter at zero; "0" is noise. If the zero is the content ("0 results"), write it as text.
Tone is urgency: neutral for totals, action for what waits for the user, danger for failures and unread notifications, accent for assistant output. Never pick a tone for looks.
At most one solid counter per row, nav or tab bar; the rest are soft. The xs disc is always solid danger.
The counter comes after the label it counts ("Inbox 12"), separated by the parent's gap, never before it.
Stays round at one digit and widens with two; never squash it into a fixed width.
Do not animate count changes (no bounce, no flash). Update the number; announce with aria-live on the region if it matters.
Accessibility
The number is plain text and is read as such; give it context with the adjacent label or a visually hidden suffix ("12 unread").
On an icon button, put the count in the button's aria-label ("Notifications, 3 unread") and keep the disc aria-hidden.
Live counts: aria-live="polite" on the containing region (the nav item or tab), not on the counter itself.
Color is reinforcement only; the label next to the counter carries the meaning.
Data display
Avatar .cn-avataredit
A person or company as a small picture in the reference's seven sizes (16 → 64px): photo when there is one, semibold initials on gray-100 or a placeholder icon when there is not, a 0.5px contrast ring, and optional status dot, verified tick or company badge on the corner. Circles are people; squares are companies.
Use: Next to a name in tables, lists, comments, assignee fields, menus and headers; with the avatar label group when the name and email sit beside it. Use AvatarGroup for several people in one slot. Not a button: wrap it in a Button or Link when it opens something.
Sizesxxs 16 · xs 24 · sm 32 · md 40 · lg 48 · xl 56 · 2xl 64. Semibold initials on gray-100 with the 0.5px contrast ring.
Company badge and placeholdersA 14px company badge on a person; the person placeholder icon; the square company placeholder (building icon or initials) and a square logo.
Tones and the focusable triggerneutral for people; the other tones only when the entity has that state. A button avatar gets the focus ring; .cn-avatar-add is the dashed add button.
.cn-avatar<span> — Sized box, position relative, overflow visible so the badges can sit on the edge; draws the 0.5px inner contrast ring. Carries role="img" + aria-label when showing initials. Add data-border for the 1px outer ring, tabindex or make it a button when focusable.
.cn-avatar__image<img> · optional — The photo, cropped with object-fit cover, clipped by the root radius, with the reference's faint 1px light rim at the top and bottom edge. Needs alt.
.cn-avatar__fallback<span> · optional — Initials (1–2 letters, semibold, 12 → 24px) centered on the gray-100 fill. Shown when there is no image.
.cn-avatar__icon<svg> · optional — Placeholder icon (a person, or a building for companies) inside the fallback: 12 → 32px, fg-subtle.
.cn-avatar__status<span> · optional — Presence dot at the bottom-right (6 → 16px) with a 1.5px surface ring so it reads on any photo. Hidden unless data-status is a value other than none.
.cn-avatar__verified<svg> · optional — The blue verified tick at the bottom-right (10 → 20px): a blue-500 seal with a white check.
.cn-avatar__badge<span> · optional — Company-logo badge at the bottom-right (8 → 20px): a round brand-50 tile with a 1.5px surface ring holding a small logo image.
Props
prop
values (default outlined)
meaning
size
xxsxssmmdlgxl2xl
Outer size: xxs 16px (inline in text, tags), xs 24px (dense tables, menus, kanban cards), sm 32px (lists, table rows), md 40px (default: account cards, comments, table rows with two lines), lg 48px (headers, drawers), xl 56px and 2xl 64px (profile and settings pages).
shape
circlesquare
circle = a person; square (radius 6) = a company, workspace or integration. Never mix shapes in one list.
tone
neutralaccentinfosuccesswarning
Tint of the initials fallback. neutral (gray-100 with gray-500 initials, the reference) for people; accent only for the product's own assistant; info/success/warning only when the entity itself has that state. Never assign colors per person.
status
noneonlineawaybusyoffline
Presence dot: none hides it (default); online = green-500, away = amber, busy = red, offline = gray-300. Only for real-time presence.
States
state
selector
meaning
focus
:focus-visible
Keyboard focus when the avatar is a button or link (account menu trigger): the 4px brand ring. (make the avatar a <button> or <a>, or add tabindex="0")
Rules
Circle = a person. Square (radius 6) = a company, a workspace or an integration. One shape per list.
Initials: first + last name (2 letters) from xs up; a single letter at xxs. Semibold, gray-500 on gray-100.
Fallback tone is neutral for people. accent is reserved for the product's assistant. Never give people random colors.
Show the status dot only for real-time presence that matters; never as decoration; data-status="none" otherwise. The verified tick and the company badge are mutually exclusive with the dot.
Photos are cropped with cover, never stretched; the only frame is data-border (1px outer ring) on profile pages.
Sizes follow the slot: xxs inline in text and tags, xs in dense tables and menus, sm in list rows, md in account cards and 72px table rows, lg in headers and drawers, xl / 2xl on profile pages.
Next to a name and email use the avatar label group (.cn-avatar-label-group) so the text sizes and gaps match the avatar size.
The avatar is not a control unless it is the account menu trigger: then it is a <button aria-haspopup="menu"> and gets the focus ring; do not add hover styles.
Always pair with the name in text nearby except in tight groups; the picture is never the only identification.
Accessibility
Photo: alt is the person's name when the name is not adjacent text; alt="" when it is, to avoid reading the name twice.
Initials: put role="img" and aria-label="Full name" on the root and aria-hidden="true" on the initials span.
Presence and verification must be available as text: include them in the alt or aria-label ("Maya Chen, online", "Elena Rossi, verified"). The dot and tick alone are not accessible.
When clickable, the avatar is a <button> or <a> with an aria-label and shows the focus ring; a wrapping Link works too.
The add button (.cn-avatar-add) needs aria-label ("Add teammate").
Data display
AvatarGroup .cn-avatar-groupedit
A row of overlapping avatars for the people on one thing, each separated by a 1.5px surface ring, ending in a "+3" gray disc when there are more than fit and optionally the dashed add button.
Use: Assignees, participants, watchers: several people in one slot (a table cell, a card footer, a header). One person is an Avatar, not a group of one. Not for companies or integrations.
Three people40px avatars overlapping by 8px, each with the 1.5px surface ring; the first is drawn on top.
.cn-avatar-group<div> — Inline-flex row, role="group" with an aria-label that summarizes who is in it.
.cn-avatar-group__item<span> — An Avatar root that also carries this class. Gets the 1.5px surface ring and the negative overlap; the group's data-size overrides the avatar size.
.cn-avatar-group__overflow<span> · optional — The "+N" disc, same size as the avatars, gray-50 with a gray-200 ring and 12–16px semibold gray-500 text. A <button> when it opens the full list.
.cn-avatar-group__add<button> · optional — The add button: a dashed gray-300 circle on white with a plus icon, same size as the avatars, 8px after the row.
.cn-avatar-group__icon<svg> · optional — The 16 / 20px plus icon inside the add button.
Props
prop
values (default outlined)
meaning
size
xssmmdlg
Avatar size inside the group: xs 24px (table cells, kanban cards), sm 32px (list rows, card footers), md 40px (default: card headers), lg 48px (page headers). Pass the same value as data-size on each avatar so initials scale too.
Rules
Maximum five visible avatars. Beyond that, the overflow disc shows the remaining count ("+3"); never a second row.
Source order is relevance order: owner first, then assignees by recency. The first avatar is drawn on top so it is fully visible.
One size and one shape per group; pass the group size as data-size on every avatar as well.
Initials inside a group are fine at sm and up; at xs use a single letter so the 4px overlap does not clip them. Photos are unaffected.
The overflow disc is a <span> unless clicking it opens the full list; then it is a <button> with aria-label "Show all N people".
The add button belongs to groups the user can edit (assignees, members); never in read-only cells.
The rings are bg-surface: place groups on surface or canvas, not on tinted fills.
No status dots inside a group; the overlap hides them. Show presence in the expanded list instead.
A group of one is an Avatar. A group of zero is nothing (or "Unassigned" text), never an empty disc.
Accessibility
root has role="group" and an aria-label summarizing the members ("Assigned to Maya Chen, Daniel Costa and 3 others") so it is not read one avatar at a time; each avatar keeps its own label.
The overflow count is real text and is announced; as a button it needs an aria-label with the total count.
The add button needs aria-label ("Add person") and is the only focusable element besides an overflow button.
Overlap is visual only; the DOM order is the meaningful order.
Data display
AgentPresence .cn-agent-presenceedit
Shows an AI agent as a teammate: avatar with a status dot, the name, a state pill and a live line saying what it is doing right now. The accent color appears only here and only when an agent is working.
Use: Use wherever a user needs to know whether an agent is active and on what: the team rail, a conversation header, a run list, a kanban card. Not for human users (use Avatar) and not for static labels (use Badge). The activity line must be live; if there is nothing current, show the last run time instead.
Row, working
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
VeraWorkingDrafting a reply to Marisco del Sur in Spanish…45s
HTML
<div class="cn-agent-presence" data-variant="card" data-size="sm" data-status="working" style="width:320px"><span class="cn-agent-presence__avatar"><svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg><span class="cn-agent-presence__dot"></span></span><span class="cn-agent-presence__name">Vera</span><span class="cn-agent-presence__state">Working</span><span class="cn-agent-presence__activity">Drafting a reply to Marisco del Sur in Spanish…</span><span class="cn-agent-presence__time">45s</span></div>
Recipes
Team railRows stack with no divider; waiting agents sort above working ones because they need the user.
VeraWorkingSearching importers in South Korea…6m
IRIrisWaiting for youNeeds the price list for Hoffmann GmbH1h
THTheoIdleLast run today 07:003h
HTML
<div style="display:flex;flex-direction:column;width:420px;border:1px solid var(--cn-color-border-default);border-radius:var(--cn-radius-xl);background:var(--cn-color-bg-surface);padding:var(--cn-space-2) var(--cn-space-4)"><div class="cn-agent-presence" data-variant="row" data-size="md" data-status="working"><span class="cn-agent-presence__avatar"><svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg><span class="cn-agent-presence__dot"></span></span><span class="cn-agent-presence__name">Vera</span><span class="cn-agent-presence__state">Working</span><span class="cn-agent-presence__activity">Searching importers in South Korea…</span><span class="cn-agent-presence__time">6m</span></div><div class="cn-agent-presence" data-variant="row" data-size="md" data-status="waiting"><span class="cn-agent-presence__avatar">IR<span class="cn-agent-presence__dot"></span></span><span class="cn-agent-presence__name">Iris</span><span class="cn-agent-presence__state">Waiting for you</span><span class="cn-agent-presence__activity">Needs the price list for Hoffmann GmbH</span><span class="cn-agent-presence__time">1h</span></div><div class="cn-agent-presence" data-variant="row" data-size="md" data-status="idle"><span class="cn-agent-presence__avatar">TH<span class="cn-agent-presence__dot"></span></span><span class="cn-agent-presence__name">Theo</span><span class="cn-agent-presence__state">Idle</span><span class="cn-agent-presence__activity">Last run today 07:00</span><span class="cn-agent-presence__time">3h</span></div></div>
Matrix
variant ↓ / size →
sm
md
chip
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
row
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
card
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
VeraWorkingReading 3 replies from Nakamura Trading Co.…2m
Anatomy, props, states, rules
Anatomy
.cn-agent-presence<div> — The element. Chip is inline; row and card are a grid: avatar | name + state | time on the first line, activity on the second.
.cn-agent-presence__avatar<span> — 24px circle (20px in sm) in the accent wash with the agent’s initials or the spark icon in fg-accent. Contains the dot.
.cn-agent-presence__dot<span> — 8px status dot at the avatar’s bottom-right with a 2px surface ring. Pulses (halo) only when working.
.cn-agent-presence__name<span> — label-sm, the agent’s name ("Vera", "Iris"). Never a role or a model name.
.cn-agent-presence__state<span> · optional — Kicker-style pill: "Working", "Waiting for you", "Idle", "Offline". Hidden in chip.
.cn-agent-presence__activity<span> · optional — body-xs muted, truncated, the live "now" line: "Reading 3 replies from Nakamura…". Hidden in chip.
.cn-agent-presence__time<span> · optional — code-sm subtle, how long the current state has lasted ("2m", "40m"). Hidden in chip.
Props
prop
values (default outlined)
meaning
variant
chiprowcard
chip = inline pill with avatar, name and dot only (inside text, headers, kanban cards); row = one line in a list with the state pill, activity and time (default); card = the row on a bordered surface with padding, for a team grid or a dashboard.
size
smmd
md = 24px avatar, label-sm name (default); sm = 20px avatar, label-xs name, for dense tables and chips inside text.
status
workingwaitingidleoffline
working = the agent is acting right now: accent dot with a pulse and an accent pill; waiting = it needs the user (approval, a file): warning dot and pill; idle = available, nothing running: neutral; offline = paused or disconnected: muted dot and the whole element at 70% opacity.
Rules
Orchid/accent means "an agent is working". This is the only place accent is used as a status; never use it for human users or for success.
The activity line is live and concrete: verb + object + count ("Reading 3 replies from Nakamura…"). It updates in place without re-rendering the row.
When nothing is happening, the activity line shows the last run ("Last run 40 min ago · verified 38 companies"), never "Nothing to do".
Agents have a name and a task, not a personality: no "I", no emoji, no "thinking…", no model names.
State pill text is fixed: Working / Waiting for you / Idle / Offline. Do not invent states; put details in the activity line.
waiting sorts above working in any list: it is the one status that needs the user.
Chips carry no activity; if the reader needs to know what the agent is doing, use a row.
The pulse is the only motion: 1.8s halo on the dot while working, off under prefers-reduced-motion.
Accessibility
The status is text (the pill) and never only the dot color; chips keep the status in an aria-label on the root ("Vera, working").
The activity line is a polite live region (aria-live="polite" on the list, not on each row) so screen readers hear changes without interruption.
Time is relative and short ("2m"); pair it with a title attribute carrying the absolute time.
Initials in the avatar are aria-hidden when the name is visible next to them.
Data display
Stat .cn-statedit
A metric readout with a compact mono label and one semibold tabular number, a change indicator with an arrow (green up, red down) or as a pill, a "vs last month" note, optionally a featured icon above and a "View report" footer. Direction is a fact; whether it is good is a separate attribute.
Use: Dashboards and page headers that summarize a workspace ("Active users 2,420", "Conversion 4.2%"). Rows of 3–4 tiles. Not for counts inside navigation (Counter) or for a value inside a table (plain tabular text).
Metric card (default)White card with a fine outline, 20 × 24px padding; 14px medium label, a token-sized semibold value, green arrow + "12%", muted "vs last month".
.cn-stat<div> — Column of media, label, value, delta + description, actions. Plain, or framed as the metric card (white, gray-200 ring, flat edge and token-defined corners, 20 × 24px padding).
.cn-stat__media<div> · optional — Optional 40px FeaturedIcon (modern, gray) above the label, 16px before it.
.cn-stat__label<div> — What is measured, 14px medium fg-muted. 1–3 words ("Active users").
.cn-stat__value<div> — The number, semibold, tabular figures: 24 / 30 / 36px by size. Include the unit in the string ("41%", "2.1h", "$412k").
.cn-stat__delta<div> · optional — Optional change vs the previous period: 16px arrow + 14px medium text ("12%"). Colored by trend and sentiment; data-badge renders it as a tinted pill.
.cn-stat__icon<svg> · optional — 16px arrow inside the delta: up, down or flat, matching data-trend.
.cn-stat__description<div> · optional — Optional 14px fg-muted note after the delta: period, source or definition ("vs last month").
.cn-stat__actions<div> · optional — Optional footer on a hairline (bleeds to the card edges, 16 × 24px padding) holding a link-color Button ("View report").
Props
prop
values (default outlined)
meaning
size
smmdlg
Size of the value: sm = 24/32 semibold (inside cards and drawers); md = 30/38 (the default readout, dashboard rows); lg = 36/44 (one hero number per page).
variant
plaincard
card = the metric card: white, gray-200 ring, flat edge and token-defined corners, 20 × 24px padding (standalone tiles on the canvas); plain = no frame, for stats inside a Card, a header or next to a chart.
trend
updownflat
Direction of the delta: up = increased (green), down = decreased (red), flat = unchanged (muted). Direction only; add data-sentiment="good|bad" when up is bad or down is good.
Rules
Label is 14px medium muted, 1–3 words, names the metric ("Active users"), never the value type ("Count").
The value carries its unit in the string ("41%", "2.1h", "$412k") and uses tabular figures; format with the locale, max 5 significant characters plus unit.
data-trend is the direction of change (the arrow), not a judgement. When up is bad (bounce rate, response time) or down is good, add data-sentiment="good|bad" so the color tells the truth.
The delta is the change ("12%", "0.8 pts") and the description names the period ("vs last month"); no delta at all beats a delta without a period.
Rows of stats: 3–4 tiles, equal width, same size and variant, 24px apart, labels aligned on one baseline. Never a grid of nine tiles.
One lg (hero) stat per page at most; md in dashboard rows; sm inside cards, drawers and comparisons.
Use variant="card" on the canvas; plain inside a Card, a page header or next to a chart (ChartFrame).
The only action is the "View report" link in the footer; no buttons inside the tile. If the whole tile opens a report, wrap it in a Link.
Accessibility
Reading order is label, value, delta, description; the DOM must match so screen readers announce "Active users, 2,420, up 12% vs last month".
The arrow is decorative (aria-hidden); the delta text must state the direction in words or sign ("+12%", "−18%"), never rely on the arrow or color.
Use a real heading only when the stat is a section of its own; inside a row of tiles keep them as divs so the page outline stays clean.
Ensure the value text is selectable and not an image; no SVG numbers.
Data display
Table .cn-tableedit
Rows of records in the reference table: a gray-50 header row with 12px medium labels, 64px rows (72 with avatars) on gray-200 hairlines, 14px gray-600 cells with ink medium primary text, a checkbox column, sortable headers and gray-50 hover / selected rows; optionally framed as a card (radius 12, ring, shadow-xs).
Use: Collections of similar records that people scan and compare (team members, customers, invoices, files). For rows with one line of text and an avatar use List; for key/value pairs use DescriptionList; for a handful of visual items use Cards. Put the table in a Card with a CardHeader and a Pagination footer, or use variant="card" standalone.
Team members (card, comfortable, sorted, one selected)Component previewThe reference team table: 44px gray-50 header, 72px rows with a 40px avatar and two lines (primary ink medium, secondary muted), a 44px checkbox column, Name sorted ascending, Role sortable on hover, a help icon on Status, ghost + link actions.
Invoices (default density, in a Card with header and pagination)Component preview64px rows; the Card supplies the frame, a CardHeader names it and a minimal Pagination closes it. Numbers right-aligned and tabular.
.cn-table<table> — The <table>: width 100%, separate borders with zero spacing so radii and sticky headers work; 14px text in fg-muted. Add data-interactive when rows open something and data-sticky to pin the header inside a scroll container.
.cn-table__caption<caption> · optional — Optional visible name of the table (14px medium fg-muted) above the header, left-aligned; prefer a CardHeader.
.cn-table__header-cell<th> — Column header: 12px medium fg-muted, 8 × 24px padding, no wrap, scope="col". Sortable headers wrap their text in a <button> and carry aria-sort; a help icon may follow the label.
.cn-table__help<svg> · optional — 16px help-circle after a header label, fg-subtle, with a Tooltip explaining the column.
.cn-table__body<tbody> — The data rows.
.cn-table__row<tr> — One record, 64px (56 compact, 72 comfortable). data-selected (or aria-selected inside a grid) fills it gray-50; hover fills it gray-50 only when the table is data-interactive.
.cn-table__cell<td> — A value: 14px fg-muted, 16 × 24px padding (12 × 20 compact), gray-200 hairline below, vertical-align middle. data-align="right" for numbers (tabular figures), "center" for icons.
.cn-table__primary<span> · optional — The identifying text of a row (a name), 14px medium in ink; stack it over a secondary line inside an avatar label group.
.cn-table__secondary<span> · optional — The supporting line under the primary text (handle, email), 14px fg-muted.
.cn-table__checkbox<td> · optional — The 44px selection column (add this class alongside the cell / header-cell class): 24px left padding, 0 right, a Checkbox md; the next cell pulls its padding to 12px.
.cn-table__sort<svg> · optional — 12px arrow after a sortable header's text (stroke 3). The unsorted chevron-selector shows on hover; the arrow shows when aria-sort is set and flips for ascending.
.cn-table__actions<td> · optional — The last cell, right-aligned, holding one or two sm ghost / link Buttons or a ⋯ IconButton with a Menu. Add this class alongside the cell class (and on its header cell).
Props
prop
values (default outlined)
meaning
density
compactdefaultcomfortable
Row height and cell padding: compact = 56px rows, 36px header, 12 × 20px cells (the reference sm); default = 64px rows, 44px header, 16 × 24px cells; comfortable = 72px rows for two-line avatar cells (the reference md).
variant
defaultstripedborderedcard
default = hairlines between rows only (inside a Card with padding none); striped = alternate rows gray-50, for wide tables with many columns; bordered = full grid lines, for numeric matrices; card = the table draws its own frame: radius 12, gray-200 ring, shadow-xs, for standalone tables on the canvas.
States
state
selector
meaning
rowHover
[data-interactive] tbody tr:hover
On a body row, not the root: gray-50 fill while hovered. Only when the root has data-interactive (rows open a record). (data-interactive on the <table>; native :hover on the row)
rowSelected
tr[data-selected], tr[aria-selected="true"]
On a body row: gray-50 fill (the reference highlights selected rows like hovered ones); the checkbox shows the selection. (data-selected on the <tr> (aria-selected="true" inside role="grid"))
sorted
th[aria-sort]
On a header cell: the sort arrow is visible; ascending flips it. (aria-sort="ascending|descending" on the <th>)
sticky
[data-sticky]
Header cells stick to the top of the nearest scroll container. (data-sticky on the <table>, inside a container with overflow auto)
Rules
Column headers are 12px medium, sentence case, 1–2 words, no trailing colon; never uppercase.
Row heights come from density only: 56 / 64 / 72px; comfortable for cells with an avatar and two lines, compact for dense operational tables.
The first column identifies the row: primary text in ink medium (name, ID) over an optional secondary line, inside an avatar label group when there is a picture. It is the widest and the only one that may truncate.
Numbers are right-aligned with data-align="right" (tabular figures) and never truncated; text is left-aligned; center only icons and checkboxes.
The selection column is the 44px checkbox cell with a Checkbox md; the header checkbox selects all. Selected rows fill gray-50, like hover.
Row hover only when rows open something (data-interactive); the fill is gray-50 and never changes the row height.
Actions live in the last column, right-aligned, as at most two sm ghost / link Buttons or one ⋯ IconButton with a Menu, visible at rest (never only on hover).
Statuses in cells are Badge sm with a dot; people are the avatar label group; never an icon without text.
One sorted column at a time, shown by aria-sort and the 12px arrow; the chevron-selector appears on hover for other sortable columns.
Empty or loading tables replace the body with an EmptyState or Skeleton rows; never render a bare header.
Use variant="card" for standalone tables on the canvas; inside a Card with padding="none" use the default variant so the card supplies the frame.
Accessibility
A real <table> with <th scope="col"> in the header (and scope="row" on the identifying cell when useful). Never a grid of divs.
Name the table with a CardHeader (aria-labelledby), a visible caption or aria-label.
Sortable headers contain a <button>; aria-sort on the <th> reflects the current order. The button label names the column; announce the next order in a visually hidden hint if needed.
Row checkboxes have aria-label "Select {name}"; the header checkbox "Select all". aria-selected on rows is valid only inside role="grid"; in plain tables use data-selected plus the checkbox.
Interactive rows also need a focusable element per row (a link or button in the identifying cell); the row hover is not the only affordance.
A scroll container with a sticky header gets tabindex="0" and an aria-label so keyboard users can scroll it.
Data display
List .cn-listedit
Stacked rows with a leading slot (avatar, icon, checkbox), a 14px medium ink title with an optional muted description, and a trailing slot (badge, meta, chevron). Gray-200 hairlines between rows, gray-50 hover and current rows; the same rhythm as a table row. Rows can be links or buttons.
Use: Collections where each item is one thing with a name and a little context: team members, conversations, settings entries, files, integrations. When people compare several columns of values, use Table. For key/value pairs use DescriptionList.
Team members with avatars and roles72px rows: Avatar md leading, ink medium name over the muted email, Badge sm trailing.
.cn-list<ul> — Column of items. <ul> with <li> items for static lists, <nav>/<div> with <a>/<button> items for interactive lists. Items are direct children.
.cn-list__item<li> — One row: flex, centered, 16 × 24px padding (12 × 20 compact), min height 72px with an avatar (56 compact). Also an <a> or <button> when the whole row is one action.
.cn-list__leading<span> · optional — Optional slot before the content: Avatar md (sm at compact), a 20px icon or a Checkbox. Does not shrink.
.cn-list__content<span> — Flexible middle: title over description, both truncating.
.cn-list__title<span> — 14px medium in ink, one line, truncates with an ellipsis.
.cn-list__description<span> · optional — 14px fg-muted under the title, one line, truncates.
.cn-list__trailing<span> · optional — Optional slot after the content: Badge sm, Counter, a timestamp, a Switch or a sm ghost Button. Does not shrink.
.cn-list__chevron<svg> · optional — 20px chevron-right in fg-subtle for items that navigate. Last child of link items.
Props
prop
values (default outlined)
meaning
density
compactdefault
default = 72px min rows with 16 × 24px padding (two-line items with a 40px avatar); compact = 56px min rows with 12 × 20px padding (single-line items, settings, sm avatars).
variant
plaincard
plain = no frame, for lists inside a Card (padding none) or a drawer; card = the list draws its own frame (radius 12, gray-200 ring, shadow-xs) on a surface, for standalone lists on the canvas.
States
state
selector
meaning
hover
> a:hover, & > button:hover
On an interactive item (an <a> or <button> row), not the root: gray-50 fill. (make the item an <a href> or <button>; native :hover)
focus
> a:focus-visible, & > button:focus-visible
On an interactive item: inset 2px brand ring so it stays inside the frame. (native :focus-visible on the item)
current
> [aria-current="true"], & > [data-selected]
On an item: gray-50 fill for the current or selected row (the reference highlights like hover); the title stays ink. (aria-current="true" (navigation) or data-selected (selection) on the item)
disabled
> [aria-disabled="true"], & > button:disabled
On an item: 50% opacity, no pointer events. (aria-disabled="true" on the item, or disabled on a button item)
Rules
Every item has a title (14px medium ink); the description is optional, 14px muted and never longer than one line. Longer text belongs in the record, not the list.
One leading slot and one trailing slot per item at most. Leading = identity (Avatar md, a 20px icon, a Checkbox); trailing = state or meta (Badge sm, Counter, timestamp, Switch, one sm Button).
If the whole row is one action, the item is an <a> (navigate) or <button> (act) and shows the chevron only when it navigates to a page. Do not put buttons inside link rows.
Rows never change height on hover; hover, current and selected are the same gray-50 fill.
Use variant="card" only when the list stands alone on the canvas; inside a Card (padding none) or a drawer use plain so the Card supplies the frame.
compact (56px, sm avatars) for single-line items and settings; default (72px, md avatars) when items have two lines. One density per list.
Order by what the user is looking for (recency, priority), not alphabetically by default.
Long lists paginate or virtualize after ~50 items; do not stack hundreds of rows.
Accessibility
Static lists are <ul>/<li>; navigation lists are <nav> with <a href> items; pick-one lists use role="listbox" with role="option" and aria-selected on button items.
aria-current="true" (or "page") marks the current navigation item; data-selected alone is only visual.
Interactive rows are one focus stop each with the inset ring; the title text is the accessible name, so it must be meaningful on its own.
Avatars in rows are decorative (aria-hidden or alt="") because the title names the person; presence must be in text when it matters.
Truncated titles keep the full text in the DOM (ellipsis is CSS); add a title attribute only when the truncation hides essential information.
Data display
DescriptionList .cn-description-listedit
Key/value pairs: a 14px medium muted term and its ink value, side by side on a 160px column or stacked, divided by gray-200 hairlines. The quiet way to show the facts of one record.
Use: Details of one entity (a workspace, an invoice header, a customer profile) in drawers, cards and detail pages. For many records use Table; for a list of things use List.
Horizontal, divided160px term column, 14px medium muted terms, ink values, hairlines with 12px of padding.
Prefers email over Slack. Decision maker is the head of product, not the CEO.
HTML
<dl class="cn-description-list" data-layout="horizontal" data-size="md" data-divided="yes" style="width:100%;max-width:560px"><div class="cn-description-list__item"><dt class="cn-description-list__term">Owner</dt><dd class="cn-description-list__detail"><span class="cn-avatar-label-group" data-size="sm"><span class="cn-avatar" data-size="xs" data-shape="circle" data-tone="neutral" data-status="none" role="img" aria-label="Maya Chen"><span class="cn-avatar__fallback" aria-hidden="true">MC</span></span><span class="cn-avatar-label-group__text"><span class="cn-avatar-label-group__name">Maya Chen</span></span></span></dd></div><div class="cn-description-list__item"><dt class="cn-description-list__term">Status</dt><dd class="cn-description-list__detail"><span class="cn-badge" data-tone="success" data-variant="soft" data-size="sm"><span class="cn-badge__dot"></span>Active</span></dd></div><div class="cn-description-list__item"><dt class="cn-description-list__term">Website</dt><dd class="cn-description-list__detail"><a href="#" class="cn-link">lumen.co</a></dd></div><div class="cn-description-list__item"><dt class="cn-description-list__term">Notes</dt><dd class="cn-description-list__detail">Prefers email over Slack. Decision maker is the head of product, not the CEO.</dd></div></dl>
Matrix
layout ↓ / size →
sm
md
horizontal
Workspace
Lumen
Workspace ID
ws_8f2a91c4
Owner
Maya Chen
Plan
Business
Created
March 12, 2024
Workspace
Lumen
Workspace ID
ws_8f2a91c4
Owner
Maya Chen
Plan
Business
Created
March 12, 2024
vertical
Workspace
Lumen
Workspace ID
ws_8f2a91c4
Owner
Maya Chen
Plan
Business
Created
March 12, 2024
Workspace
Lumen
Workspace ID
ws_8f2a91c4
Owner
Maya Chen
Plan
Business
Created
March 12, 2024
Anatomy, props, states, rules
Anatomy
.cn-description-list<dl> — The <dl>. Column of items; the layout prop decides how each pair sits.
.cn-description-list__item<div> — One pair. A <div> wrapping the dt and dd so they can be laid out as a row.
.cn-description-list__term<dt> — The key. 14px medium fg-muted, sentence case, no colon.
.cn-description-list__detail<dd> — The value. 14px in ink; may hold a Badge, a code value, a link or an Avatar with a name.
Props
prop
values (default outlined)
meaning
layout
horizontalvertical
horizontal = term on a 160px column, value beside it (drawers, detail pages, ≥ 360px wide); vertical = term above the value, 4px apart (narrow columns, card grids, mobile).
size
smmd
md = 14px term and value (default); sm = 12px, for dense cards and summaries.
divided
yesno
yes = a gray-200 hairline between pairs with 12px of vertical padding (scannable lists of 4+ facts); no = pairs separated by an 8px gap only (2–3 facts inside a card).
Rules
Terms are sentence-case nouns with no trailing colon ("Workspace ID", not "Workspace ID:"), 14px medium muted.
Keep terms short (1–3 words) so they fit the 160px column; if a term needs a sentence, it is not a key/value pair.
Values are facts in ink, not paragraphs. A value longer than two lines belongs in a section of its own.
Empty values show an em dash ("—") in fg-subtle, never a blank cell and never the word "null" or "N/A".
Order pairs by how people look things up (identity first, then status, then dates), not by the database schema.
Use horizontal at ≥ 360px of width; below that, or in card grids, switch to vertical.
divided="yes" for 4 or more pairs; "no" for 2–3 pairs inside a card that already has a frame.
Values may hold one component (Badge sm, avatar label group, code, Link) but not buttons; actions live in the surface's header.
Accessibility
Use the real <dl>/<dt>/<dd> elements; the wrapping <div> per pair is valid HTML and keeps the term/detail association.
Do not put interactive controls in dd besides links; a control needs a labelled form field.
Abbreviated terms (ID, VAT) get an <abbr title> or the expansion in the value on first use.
Data display
Accordion .cn-accordionedit
Stacked disclosure sections in the reference FAQ style: a full-width semibold trigger row with a chevron (or plus) that flips on the right, and muted body text that shows under it when open. Gray-200 hairlines between items; optional separated or card framing.
Use: Secondary content people open on demand: FAQ, advanced settings, details of a step, long explanations under a summary. Not for primary navigation (Tabs) and not for hiding required form fields.
Default (md), first item open16px semibold triggers on gray-200 hairlines, 20px chevron, 16px muted body.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
HTML
<div class="cn-accordion" data-variant="default" data-size="md" style="max-width:640px"><div class="cn-accordion__item" data-state="open"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-a-1-trigger" aria-expanded="true" aria-controls="faq-a-1-content">Is there a free trial available?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-a-1-content" role="region" aria-labelledby="faq-a-1-trigger">Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-a-2-trigger" aria-expanded="false" aria-controls="faq-a-2-content">Can I change my plan later?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-a-2-content" role="region" aria-labelledby="faq-a-2-trigger">Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-a-3-trigger" aria-expanded="false" aria-controls="faq-a-3-content">What is your cancellation policy?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-a-3-content" role="region" aria-labelledby="faq-a-3-trigger">We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.</div></div></div>
FAQ (lg) with plus / minus iconsThe reference FAQ section: 18px semibold, 24px padding, a 24px plus that becomes a minus when open.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
HTML
<div class="cn-accordion" data-variant="default" data-size="lg" style="max-width:768px"><div class="cn-accordion__item" data-state="open"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-b-1-trigger" aria-expanded="true" aria-controls="faq-b-1-content">Is there a free trial available?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path class="cn-accordion__icon-v" d="M8 3v10"/><path d="M3 8h10"/></svg></button></h3><div class="cn-accordion__content" id="faq-b-1-content" role="region" aria-labelledby="faq-b-1-trigger">Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-b-2-trigger" aria-expanded="false" aria-controls="faq-b-2-content">Can I change my plan later?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path class="cn-accordion__icon-v" d="M8 3v10"/><path d="M3 8h10"/></svg></button></h3><div class="cn-accordion__content" id="faq-b-2-content" role="region" aria-labelledby="faq-b-2-trigger">Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-b-3-trigger" aria-expanded="false" aria-controls="faq-b-3-content">What is your cancellation policy?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path class="cn-accordion__icon-v" d="M8 3v10"/><path d="M3 8h10"/></svg></button></h3><div class="cn-accordion__content" id="faq-b-3-content" role="region" aria-labelledby="faq-b-3-trigger">We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.</div></div></div>
SeparatedEach item is its own card (radius 12, gray-200 ring), 16px apart; use on the canvas.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
HTML
<div class="cn-accordion" data-variant="separated" data-size="md" style="max-width:640px"><div class="cn-accordion__item" data-state="open"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-c-1-trigger" aria-expanded="true" aria-controls="faq-c-1-content">Is there a free trial available?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-c-1-content" role="region" aria-labelledby="faq-c-1-trigger">Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-c-2-trigger" aria-expanded="false" aria-controls="faq-c-2-content">Can I change my plan later?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-c-2-content" role="region" aria-labelledby="faq-c-2-trigger">Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-c-3-trigger" aria-expanded="false" aria-controls="faq-c-3-content">What is your cancellation policy?<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-c-3-content" role="region" aria-labelledby="faq-c-3-trigger">We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.</div></div></div>
Card, small, with a disabled itemOne card frame with 14px triggers; the disabled trigger stays readable.
Every outgoing message needs one approval. Replies to an approved thread can be auto-approved per conversation.
Messages go out between 08:00 and 18:00 in the recipient's time zone.
Upgrade to edit signatures per sender.
HTML
<div class="cn-accordion" data-variant="card" data-size="sm" style="max-width:560px"><div class="cn-accordion__item" data-state="open"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-d-1-trigger" aria-expanded="true" aria-controls="faq-d-1-content">Approval rules<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-d-1-content" role="region" aria-labelledby="faq-d-1-trigger">Every outgoing message needs one approval. Replies to an approved thread can be auto-approved per conversation.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-d-2-trigger" aria-expanded="false" aria-controls="faq-d-2-content">Sending window<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-d-2-content" role="region" aria-labelledby="faq-d-2-trigger">Messages go out between 08:00 and 18:00 in the recipient's time zone.</div></div><div class="cn-accordion__item" data-state="closed"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="faq-d-3-trigger" aria-expanded="false" aria-controls="faq-d-3-content" disabled>Custom signatures (Business plan)<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button></h3><div class="cn-accordion__content" id="faq-d-3-content" role="region" aria-labelledby="faq-d-3-trigger">Upgrade to edit signatures per sender.</div></div></div>
Matrix
variant ↓ / size →
sm
md
lg
default
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
separated
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
card
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Yes, you can try Lumen for free for 30 days. If you want, we'll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.
Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.
We understand that things change. You can cancel your plan at any time and we'll refund you the difference already paid.
Anatomy, props, states, rules
Anatomy
.cn-accordion<div> — Column of items.
.cn-accordion__item<div> — One section. Carries data-state="open|closed", which drives the icon and hides the content. Hairline below.
.cn-accordion__icon<svg> — 20 / 24px chevron-down (rotates 180° when open) or plus (its vertical bar hides when open) at the end of the trigger, fg-subtle.
.cn-accordion__icon-v<path> · optional — The vertical bar of the plus icon; hidden when the item is open so the plus becomes a minus.
.cn-accordion__content<div> — The revealed panel: role="region", fg-muted body text, prose width, padding below. Hidden when the item is closed.
Props
prop
values (default outlined)
meaning
variant
defaultseparatedcard
default = items divided by hairlines, no frame (inside a Card or a page section); separated = each item is its own card (radius 12, ring) with a 16px gap (settings groups, FAQ on the canvas); card = the whole list in one card frame with items divided.
size
smmdlg
sm = 14px semibold triggers with 12px padding and 14px body (dense settings); md = 16px semibold triggers, 16px padding, 16px body (the default in cards and drawers); lg = 18px semibold triggers, 24px padding, 16px body with a 24px icon (the reference FAQ section).
States
state
selector
meaning
open
> [data-state="open"]
On an item, not the root: the chevron is rotated (or the plus becomes a minus) and the content is visible. (data-state="open" on the item and aria-expanded="true" on its trigger)
closed
> [data-state="closed"]
On an item: the content is display none. (data-state="closed" on the item and aria-expanded="false" on its trigger)
hover
> * > * > button:hover
On the trigger: the icon darkens; no background change. (native :hover on the trigger)
focus
> * > * > button:focus-visible
On the trigger: the 4px brand ring. (native :focus-visible on the trigger)
disabled
> * > * > button:disabled
On the trigger: 50% opacity, cannot open. (disabled on the trigger button)
Rules
Every item carries data-state="open|closed" and its trigger aria-expanded; never leave the state implicit.
Trigger labels are short noun phrases or questions in sentence case (≤ 10 words); no icons before the label, only the chevron or plus after it. One icon style per accordion.
Content is prose or a small form, capped at prose width with a 40px right inset so it never runs under the icon; it is not a place for tables or lists of records.
Default to all items closed except when there is one obvious first item; open at most one item by default.
Allow several items open at once unless the content is mutually exclusive; do not force single-open behavior for FAQs.
No hover background on the trigger; only the icon darkens. Focus ring on keyboard focus only.
default inside cards and sections; separated on the canvas for long lists; card for a compact block of 2–4 items; lg only for FAQ sections on marketing and help pages.
Do not nest accordions.
Accessibility
The trigger is a <button> inside a heading (h2–h4 at the right level) with aria-expanded and aria-controls pointing to the content id.
The content has role="region" and aria-labelledby pointing to the trigger id.
Keyboard: Enter/Space toggle; Tab moves between triggers. Arrow-key navigation is optional and must not trap focus.
Closed content is display none, so it is removed from the tab order and the accessibility tree; do not use visibility tricks that keep it focusable.
Disabled triggers stay visible and readable; if the reason matters, say it in the label ("(Business plan)").
Data display
Timeline .cn-timelineedit
Events in order down a thin vertical line: an 8px dot (or a 32px avatar) per event, a 12px timestamp, a 14px semibold title and a muted note. The current event pulses in brand; completed events are green; failures are red.
Use: Activity of one thing over time: a release, a conversation, an order, a support ticket. For a multi-step process the user walks through use Stepper; for a list of records use List.
Release activity, current event lastThree completed events (green dots), one current (brand, pulsing). 12px time over a 14px semibold title.
Release branch createdv2.4.0 cut from main by Daniel Costa.
12 pull requests merged8 features, 4 fixes.
Staging deploy approvedSofia Almeida signed off on the design review.
Production rollout in progress25% of workspaces migrated; no errors so far.
HTML
<ol class="cn-timeline" data-size="md" data-variant="default" style="max-width:480px"><li class="cn-timeline__item" data-state="complete"><span class="cn-timeline__marker" aria-hidden="true"></span><span class="cn-timeline__line" aria-hidden="true"></span><time class="cn-timeline__time" datetime="2026-09-11">Sep 8 · 09:12</time><span class="cn-timeline__title">Release branch created</span><span class="cn-timeline__description">v2.4.0 cut from main by Daniel Costa.</span></li><li class="cn-timeline__item" data-state="complete"><span class="cn-timeline__marker" aria-hidden="true"></span><span class="cn-timeline__line" aria-hidden="true"></span><time class="cn-timeline__time" datetime="2026-09-11">Sep 8 · 09:40</time><span class="cn-timeline__title">12 pull requests merged</span><span class="cn-timeline__description">8 features, 4 fixes.</span></li><li class="cn-timeline__item" data-state="complete"><span class="cn-timeline__marker" aria-hidden="true"></span><span class="cn-timeline__line" aria-hidden="true"></span><time class="cn-timeline__time" datetime="2026-09-11">Sep 9 · 14:05</time><span class="cn-timeline__title">Staging deploy approved</span><span class="cn-timeline__description">Sofia Almeida signed off on the design review.</span></li><li class="cn-timeline__item" data-state="current"><span class="cn-timeline__marker" aria-hidden="true"></span><span class="cn-timeline__line" aria-hidden="true"></span><time class="cn-timeline__time" datetime="2026-09-11">Sep 11 · 08:31</time><span class="cn-timeline__title">Production rollout in progress</span><span class="cn-timeline__description">25% of workspaces migrated; no errors so far.</span></li></ol>
Compact feed with a failureTime inline after the title; danger tone on the failure.
Rollout resumed25% of workspaces migrated.
Rollout pausedError rate above 1% in eu-west-1; Aisha Khan is investigating.
Release branch createdv2.4.0 cut from main by Daniel Costa.
12 pull requests merged8 features, 4 fixes.
Staging deploy approvedSofia Almeida signed off on the design review.
Production rollout in progress25% of workspaces migrated; no errors so far.
Release branch createdv2.4.0 cut from main by Daniel Costa.
12 pull requests merged8 features, 4 fixes.
Staging deploy approvedSofia Almeida signed off on the design review.
Production rollout in progress25% of workspaces migrated; no errors so far.
compact
Release branch createdv2.4.0 cut from main by Daniel Costa.
12 pull requests merged8 features, 4 fixes.
Staging deploy approvedSofia Almeida signed off on the design review.
Production rollout in progress25% of workspaces migrated; no errors so far.
Release branch createdv2.4.0 cut from main by Daniel Costa.
12 pull requests merged8 features, 4 fixes.
Staging deploy approvedSofia Almeida signed off on the design review.
Production rollout in progress25% of workspaces migrated; no errors so far.
Anatomy, props, states, rules
Anatomy
.cn-timeline<ol> — Ordered list of events, oldest first (or newest first for activity feeds; be consistent per screen).
.cn-timeline__item<li> — One event. Relative, left padding for the marker. data-state="complete|current" and data-tone="danger" color the marker.
.cn-timeline__marker<span> — 8px dot on the line with a 1.5px surface ring. Gray-300 by default; green when complete, brand (pulsing) when current, red for failures.
.cn-timeline__avatar<span> · optional — Optional 32px Avatar in place of the dot (an Avatar root that also carries this class) for activity by a person; the line runs behind it.
.cn-timeline__line<span> — 2px gray-200 rule from this marker to the next. Hidden on the last item.
.cn-timeline__time<time> — When it happened, 12px medium fg-subtle. Use a <time datetime>. Relative ("2h ago") or absolute, consistent per screen.
.cn-timeline__title<span> — What happened, 14px semibold in ink, past tense ("Staging deploy approved").
.cn-timeline__description<span> · optional — Optional one or two lines of detail, 14px fg-muted.
Props
prop
values (default outlined)
meaning
size
smmd
md = 14px titles with 20px between events (detail pages, drawers); sm = 12px titles and time, tighter spacing (side panels, cards).
variant
defaultcompact
default = time above the title, each event a small block; compact = time inline after the title on one line, description under it, for dense activity feeds.
States
state
selector
meaning
complete
> [data-state="complete"]
On an item, not the root: the marker is green. (data-state="complete" on the item)
current
> [data-state="current"]
On an item: brand marker with a soft pulsing halo; the title is semibold brand. (data-state="current" on the item (at most one per timeline))
danger
> [data-tone="danger"]
On an item: red marker and title for a failure (bounce, rejection, error). (data-tone="danger" on the item)
Rules
Titles are short past-tense facts ("Staging deploy approved", "12 pull requests merged"), sentence case, no trailing period.
Timestamps use a <time datetime> element in 12px medium; pick relative or absolute per screen and keep it consistent across all events.
At most one data-state="current" per timeline; it is the only pulsing element on the page.
Marker colors mean state: gray = happened, green = completed step, brand = happening now, red = failed. Never other colors.
Use the avatar marker when the event is something a person did; the dot when it is something the system did. One marker style per timeline.
Descriptions are one or two lines of detail; anything longer (a message body) links to the record.
Keep one direction per screen: oldest first for a process, newest first for an activity feed.
Do not put buttons or badges inside events; the timeline reports, the surface acts. A link inside the description is fine.
Long timelines collapse older events behind a "Show 12 earlier events" link Button; do not render hundreds of events.
Accessibility
Use <ol> and <li> so the order and count are announced; the marker, avatar and line are aria-hidden decoration.
State must be in the text, not only in the marker color: the title or description says "failed", "approved", "in progress".
The pulse animation stops under prefers-reduced-motion (handled by the base stylesheet); the current item still stands out by color and weight.
<time datetime> carries the machine-readable date so relative labels ("2h ago") remain meaningful.
Data display
Kanban .cn-kanbanedit
A board of columns with draggable cards. Columns are transparent with a header that names the stage, its count badge and who owns it; cards are small white cards (radius 12, gray-200 ring, shadow-xs) with a 14px medium title, a muted meta line and a footer of status Badge + Avatar.
Use: Work that moves through stages and is owned by someone at each stage: tasks, deals, tickets. When the stage does not matter, use a List; when people compare many fields, use a Table.
Board: in progress, waiting on you, blockedComponent previewEach column names its owner in a badge and shows its count. Cards carry a status Badge and the person responsible.
.cn-kanban<div> — Horizontal row of columns, 24px apart, scrolls sideways, columns aligned to the top.
.cn-kanban__column<section> — One stage: 320px wide (288 compact), transparent. data-owner="agent|you|action" colors the owner badge.
.cn-kanban__column-header<header> — Row with title, count and owner badge, 8px apart, 36px tall.
.cn-kanban__column-title<h3> — Stage name, 14px semibold in ink.
.cn-kanban__column-count<span> — Number of cards in a 22px gray badge (12px medium, gray-50, gray-200 ring).
.cn-kanban__column-owner<span> — Owner badge pushed to the right (12px medium, radius 6): "Engineering", "Product", "Needs action". Color follows data-owner on the column.
.cn-kanban__column-body<div> — Drop zone holding the cards 12px apart, min height 128px, radius 12. data-over shows a dashed gray-300 outline on gray-50 while a card hovers over it.
.cn-kanban__card<article> — One item: white, gray-200 ring, shadow-xs, radius 12, 16px padding, grab cursor, focusable. data-state="dragging" lifts it.
.cn-kanban__card-title<h4> — The thing's name, 14px medium in ink, up to two lines.
.cn-kanban__card-meta<div> — One line of context, 14px fg-muted ("LUM-241 · Due Sep 19").
.cn-kanban__card-footer<div> — Status (Badge sm) on the left, owner (Avatar xs) on the right, 8px above.
Props
prop
values (default outlined)
meaning
density
compactdefault
default = 320px columns, 16px card padding, 12px between cards (boards with up to ~8 cards per column); compact = 288px columns, 12px card padding, 8px between cards, for boards with many cards.
States
state
selector
meaning
cardHover
> * > * > article:hover
On a card, not the root: the ring darkens to gray-300. (native :hover on the card)
cardFocus
> * > * > article:focus-visible
On a card: the 4px brand focus ring (cards are focusable for keyboard moves). (tabindex="0" on the card; native :focus-visible)
cardDragging
> * > * > [data-state="dragging"]
On a card while it is being dragged: shadow-lg, 1° tilt, grabbing cursor. (data-state="dragging" on the card)
columnOver
> * > [data-over]
On a column body while a card is dragged over it: dashed gray-300 border on a gray-50 fill. (data-over on the column body)
Rules
Every column names an owner with data-owner: agent (a team is working on it), you (waiting for the user) or action (something went wrong or is overdue). The owner badge is the reader's first question answered.
Column titles are stages or owners, 1–3 words; the count badge is always shown, even when zero.
Cards show exactly: title (14px medium), one meta line, one status Badge sm and one Avatar xs. No descriptions, no buttons, no menus on the card; open the record for details.
Card height comes from content only; do not force equal heights or add images.
Columns are 320px (288px compact) and never stretch; the board scrolls sideways. Keep it to 3–6 columns.
Drop feedback is the dashed column body (data-over) and the lifted card (data-state="dragging"); do not add insertion lines or ghost cards.
Empty columns keep their min-height so there is somewhere to drop; show "Nothing here" in fg-subtle 14px when a column is empty for a while.
Status tone follows the owner: brand (with dot) while a team works, warning when the user must act, danger for failures, neutral otherwise.
Accessibility
The board is role="list" of columns; each column is a <section> with an aria-label that includes its title and count; each card is an <article> with a heading.
Cards are focusable (tabindex="0"). Provide a keyboard way to move a card (a "Move to…" action in the record or a menu on Enter); drag and drop is never the only way.
Announce moves with a polite live region ("Approve pricing page copy moved to In progress").
Owner and status are text (the owner badge, the status badge), never only a color; the dot is reinforcement.
Data display
ChartFrame .cn-chart-frameedit
The card every chart lives in: an 18px semibold title and muted description on the left, a legend of round swatches on the right, a fixed-height body for the chart and a small footer line. White, gray-200 ring, shadow-xs, radius 12. The chart itself is your SVG or library output; the frame gives it the system's type, colors and spacing.
Use: Wrap every chart (bars, lines, areas, donuts) in dashboards and reports. Not for single numbers (Stat) and not for tables of values (Table).
Bars, two seriesSeries 1 = brand-600, series 2 = brand-300. Gridlines gray-100, axis text 12px gray-500, round 8px swatches.
.cn-chart-frame__legend-item<span> · optional — Swatch + series name, 8px apart.
.cn-chart-frame__swatch<span> · optional — 8px round swatch with a 0.5px inner contrast ring, filled with the series color via an inline style using a token variable.
.cn-chart-frame__body<div> — Where the chart goes. Position relative, min-height from size, the SVG fills the width; axis text is 12px fg-subtle. Also the place for an EmptyState or Skeleton.
Minimum height of the body: sm 160px (sparklines and small multiples, 16 × 20px padding), md 240px (dashboard tiles), lg 320px (the main chart of a report page).
Rules
Series colors in order: 1 = brand-600 (bg-action), 2 = brand-300, 3 = gray-400. Semantic colors (success, warning, danger, info) only when the series means that state. Maximum 6 series.
Gridlines are gray-100 (border-subtle), axis text is 12px gray-500, no axis lines heavier than a hairline. No 3D, no gradients beyond a faint area fill, no shadows, bars with 2px radius.
The title lives in the frame header, never inside the SVG; the SVG carries only marks, axes and labels.
Every series in the legend (8px round swatches, 12px apart), every axis with units; never rely on color alone (order and labels carry the meaning).
The chart fills the body width. Reflow axes and marks for narrow screens; for a static SVG whose labels would become too small, use data-scroll on the body with a named, focusable scroll region. Keep the minimum height from size.
One chart per frame. Small multiples are several sm frames in a grid, not one frame with subplots.
No data or loading: put an EmptyState (sm) or a Skeleton in the body; keep the header so the tile does not jump.
Numbers on the chart use the same formatting as the rest of the page (locale, units).
Accessibility
The SVG has role="img" and an aria-label that states the takeaway ("Upgrades grew from 12 to 21"), not just the chart name.
Offer the data as a table (a "View as table" link Button in the footer or a visually hidden table) for screen readers and copy-paste.
Tooltips on marks are enhancements; <title> elements inside the SVG give hover text without JS.
Check series contrast against the actual surface in each supported theme. Use labels, patterns or outlines when a lighter series alone does not reach 3:1.
Data display
AccountCard .cn-account-cardedit
The signed-in person as a row: a 40px avatar with a presence dot, name in label-md, email in body-sm muted, and a ⋯ button that opens the account Menu. A hairline card at the bottom of the Sidebar, a plain row inside menus, or a compact row in account switchers.
Use: Use wherever the current account is shown with a way to act on it: the bottom of the Sidebar and the mobile drawer, the header of the account Menu, the rows of a workspace switcher. Not for other people in lists (use List with an Avatar) and not for a profile page header (PageHeader).
Card (sidebar)The bottom-of-sidebar card: 40px avatar with a presence dot, name, email, and the selector button that opens the account Menu.
.cn-account-card<div> — The row: flex, 12px gap and padding, bg-surface, hairline, radius card, shadow-xs. Relative so a Menu can anchor to it.
.cn-account-card__avatar<span> — 40px circle with initials or an <img>; relative so the status dot sits on its edge. role="img" + aria-label including presence.
.cn-account-card__status<span> · optional — 10px presence dot at the bottom-right of the avatar with a 2px surface ring; data-status="online|away|busy|offline" colors it.
.cn-account-card__content<div> — Name over email, fills the row, truncates.
.cn-account-card__name<span> — label-md, one line, ellipsis.
.cn-account-card__email<span> — body-sm muted, one line, ellipsis.
.cn-account-card__menu<button> · optional — 32px ghost button with a 16px up/down selector icon that opens the account Menu. aria-label "Account menu", aria-haspopup, aria-expanded.
Props
prop
values (default outlined)
meaning
variant
cardplaincompact
card = hairline card with shadow-xs, the Sidebar account card. plain = no border or shadow, 8px padding, for the header of a Menu or a Popover. compact = 8px padding, 32px avatar and smaller text for rows in an account switcher.
size
smmd
md = 40px avatar, label-md name, body-sm email (sidebars, drawers). sm = 32px avatar, label-sm name, body-xs email for dense panels and popovers.
States
state
selector
meaning
hover
[data-interactive]:hover
Pointer over a clickable card (only when the whole row is the trigger, marked data-interactive): subtle fill. (data-interactive on the root and native :hover)
selected
[aria-selected="true"], &[data-selected]
The current account in a switcher: action-colored border and a 1px ring. (aria-selected="true" (inside a listbox) or data-selected on the root)
menuHover
.cn-account-card__menu:hover
Pointer over the ⋯ button (extraCss): subtle fill, ink icon. (native :hover on .cn-account-card__menu)
menuOpen
.cn-account-card__menu[aria-expanded="true"]
The account Menu is open (extraCss): the button keeps the subtle fill. (aria-expanded="true" on .cn-account-card__menu)
menuFocus
.cn-account-card__menu:focus-visible
Keyboard focus on the ⋯ button shows the focus ring (extraCss). (native :focus-visible on .cn-account-card__menu)
Rules
Shows the signed-in person only: their own name, their own email. Other people are List rows with an Avatar.
One action: the selector button opens the account Menu (profile, settings, switch account, log out). Never add a second button to the row.
Presence dots only when the product has real-time presence; otherwise omit the status part rather than showing everyone online.
card in the Sidebar and the mobile drawer; plain as the header of the account Menu or a Popover; compact for the rows of an account switcher.
Name and email truncate with an ellipsis; the full values are in the avatar's aria-label and the Menu header. Never wrap to two lines.
A clickable whole row (data-interactive) is for switchers only; in the Sidebar the row is static and the button is the trigger.
The selected account in a switcher gets the action-colored ring; hover uses the subtle fill. Never both on the same row.
Keep the avatar neutral (initials on bg-subtle or the photo); the accent tone is reserved for the AI agent.
Accessibility
The avatar carries role="img" and an aria-label with the full name and presence ("Maya Chen, online"); the dot alone is decorative.
The menu button has aria-label "Account menu", aria-haspopup="menu", aria-expanded and aria-controls pointing at the Menu; Escape closes the menu and returns focus to it.
In a switcher the rows are role="option" inside role="listbox" with aria-selected; Up/Down move, Enter selects.
Name and email are plain text so they are read in order; do not hide them with CSS in narrow layouts, truncate instead.
Contrast: name in fg-default, email in fg-muted (AA on bg-surface and bg-subtle).
Data display
ActivityFeed .cn-activity-feededit
A vertical feed of events on one object or workspace: a 32px avatar, "actor did action to target" in one line with a subtle timestamp, joined by a 2px connector. Events can carry a quoted comment, a file attachment, and the feed can end with a comment composer.
Use: Use on a record page (a deal, a project, a document) or a workspace home to show what people and agents did, newest first or oldest first per screen. For a process with known steps use Timeline; for chat use Message; for system notifications a person can act on use Notification.
SimpleOne sentence per event. The actor and the target are emphasized; the verb and the time are quiet.
MC
Maya Chencommented onQ3 launch plan
DC
Daniel Costauploadedbrand-guidelines-v3.pdf
SA
Sofia AlmeidaassignedHomepage redesign to Lucas Ferreira
With composerThe composer reuses the item layout so the connector runs into the current user's avatar.
MC
Maya Chencommented onPricing page copy
The second headline reads better. Can we A/B test both?
AK
Aisha Khanreplied
Yes — I set up the test for Monday.
LF
HTML
<div class="cn-activity-feed" data-variant="with-composer" role="feed" aria-label="Activity" aria-busy="false" style="width:100%;max-width:560px"><article class="cn-activity-feed__item"><span class="cn-activity-feed__avatar" role="img" aria-label="Maya Chen">MC</span><span class="cn-activity-feed__line" aria-hidden="true"></span><div class="cn-activity-feed__content"><div class="cn-activity-feed__header"><span class="cn-activity-feed__actor">Maya Chen</span><span class="cn-activity-feed__action">commented on</span><span class="cn-activity-feed__target">Pricing page copy</span><time class="cn-activity-feed__time" datetime="2026-09-11T08:40">1h ago</time></div><div class="cn-activity-feed__comment">The second headline reads better. Can we A/B test both?</div></div></article><article class="cn-activity-feed__item"><span class="cn-activity-feed__avatar" role="img" aria-label="Aisha Khan">AK</span><span class="cn-activity-feed__line" aria-hidden="true"></span><div class="cn-activity-feed__content"><div class="cn-activity-feed__header"><span class="cn-activity-feed__actor">Aisha Khan</span><span class="cn-activity-feed__action">replied</span><time class="cn-activity-feed__time" datetime="2026-09-11T09:00">40m ago</time></div><div class="cn-activity-feed__comment">Yes — I set up the test for Monday.</div></div></article><div class="cn-activity-feed__composer"><span class="cn-activity-feed__avatar" role="img" aria-label="Lucas Ferreira">LF</span><div class="cn-activity-feed__content"><div class="cn-textarea" data-size="sm" data-resize="none"><textarea class="cn-textarea__field" rows="2" placeholder="Leave a comment…" aria-label="Comment"></textarea></div><div class="cn-activity-feed__composer-actions"><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Comment</span></button></div></div></div></div>
Matrix
variant
simple
MC
Maya Chencommented onQ3 launch plan
DC
Daniel Costauploadedbrand-guidelines-v3.pdf
SA
Sofia AlmeidaassignedHomepage redesign to Lucas Ferreira
AK
Aisha KhanmarkedInvoice INV-3066 as paid
with-comments
MC
Maya Chencommented onQ3 launch plan
DC
Daniel Costauploadedbrand-guidelines-v3.pdf
SA
Sofia AlmeidaassignedHomepage redesign to Lucas Ferreira
AK
Aisha KhanmarkedInvoice INV-3066 as paid
compact
MC
Maya Chencommented onQ3 launch plan
DC
Daniel Costauploadedbrand-guidelines-v3.pdf
SA
Sofia AlmeidaassignedHomepage redesign to Lucas Ferreira
AK
Aisha KhanmarkedInvoice INV-3066 as paid
with-composer
MC
Maya Chencommented onQ3 launch plan
DC
Daniel Costauploadedbrand-guidelines-v3.pdf
SA
Sofia AlmeidaassignedHomepage redesign to Lucas Ferreira
AK
Aisha KhanmarkedInvoice INV-3066 as paid
Anatomy, props, states, rules
Anatomy
.cn-activity-feed<div> — The feed column (role="feed"). Flex column; width comes from the layout.
.cn-activity-feed__item<article> — One event: avatar on the left, content on the right, relative so the connector can hang under the avatar. 12px vertical padding.
.cn-activity-feed__avatar<span> — 32px circle with initials (24px in compact). role="img" + aria-label with the actor name, or a real Avatar image.
.cn-activity-feed__line<span> — The 2px connector from this avatar to the next one, border-default. aria-hidden; hidden automatically on the last item.
.cn-activity-feed__content<div> — Column next to the avatar: header line first, then optional comment, attachment or composer fields.
.cn-activity-feed__header<div> — The one-line sentence: actor, action, target, time. Wraps on narrow widths.
.cn-activity-feed__actor<span> — Who did it, label-sm semibold in fg-default ("Maya Chen").
.cn-activity-feed__action<span> — The verb phrase in body-sm muted ("commented on", "uploaded").
.cn-activity-feed__target<span> · optional — The object acted on, label-sm in fg-default ("Q3 launch plan"). Optional when the action is self-contained.
.cn-activity-feed__time<time> — When, body-xs in fg-subtle, after the sentence. Always a <time datetime>.
.cn-activity-feed__comment<div> · optional — Quoted comment text in a bg-subtle card (radius lg, 12px padding, body-sm).
.cn-activity-feed__attachment<a> · optional — A file row: icon, name and size in a hairline card. A link to the file.
.cn-activity-feed__composer<div> · optional — The comment box at the end of the feed: the current user's avatar next to a Textarea and a Comment button.
.cn-activity-feed__composer-actions<div> · optional — Right-aligned row under the textarea holding the primary sm "Comment" button.
Props
prop
values (default outlined)
meaning
variant
simplewith-commentscompactwith-composer
simple = one sentence per event with a 32px avatar (record pages). with-comments = the same rows plus quoted comment cards and file attachments (discussion on a document). compact = 24px avatars, tighter rows, for a notification-style feed in a side panel or popover. with-composer = the feed ends with a Textarea + Comment button so people can reply in place.
States
state
selector
meaning
busy
[aria-busy="true"]
The feed is loading more events: content dims slightly. Set while fetching, clear when done. (aria-busy="true" on the root)
attachmentHover
.cn-activity-feed__attachment:hover
Pointer over a file row (lives on the attachment, styled in extraCss): subtle fill and a stronger border. (native :hover on .cn-activity-feed__attachment)
attachmentFocus
.cn-activity-feed__attachment:focus-visible
Keyboard focus on a file row shows the focus ring (extraCss). (native :focus-visible on .cn-activity-feed__attachment)
Rules
Every row is one sentence in the same shape: actor, verb, target, time. Actor and target are emphasized; the verb and the time stay quiet.
Verbs are past tense and specific ("uploaded", "approved", "mentioned you in"); never "updated the record".
Pick one order per screen: newest first for a home feed, oldest first on a record where the story matters.
Timestamps are relative under 24h ("2h ago"), absolute after ("Sep 9"), always in a <time datetime>.
Comments are quoted verbatim in the subtle card and truncated after four lines with a "Show more" link Button; the feed never renders an entire email.
Attachments show name and size; the icon is the file type, not a thumbnail. One attachment row per file.
Use compact only in side panels, popovers and notification drawers; on a page the default size keeps comments readable.
The composer goes last, after the newest event, and uses a sm Textarea with a single primary "Comment" button; no toolbar.
Long feeds paginate with a "Show 20 earlier events" link Button at the end, not infinite scroll inside a card.
Accessibility
Root has role="feed" with an aria-label; each event is an <article> so screen readers can jump between them. Set aria-busy="true" on the root while loading more.
Initials avatars carry role="img" and aria-label with the full name; the connector line is aria-hidden.
The sentence must read correctly in DOM order: actor, action, target, time. Do not move the time before the actor with CSS.
Attachments are real links with the file name as the accessible name; the size is extra text, not the only label.
The composer textarea has a label ("Comment") and the button is a real <button>; Cmd/Ctrl+Enter may submit but is never the only way.
Data display
ActivityGauge .cn-activity-gaugeedit
Two or three concentric rings that each show a share of a goal, from the outer ring in the strongest brand tint to the inner one in the lightest, with a legend beside them that names each ring and its value.
Use: Use on a dashboard card to compare 2–3 related goals at a glance (sent / replied / booked; storage / bandwidth / seats). For one value use ProgressCircle; for more than three metrics or exact comparison use a bar chart in a ChartFrame; for a trend use a line chart.
Three metrics (md)Outer ring = first metric in brand.600, then brand.400, then brand.200. Offsets: 263.89 × 0.28 = 73.9, 201.06 × 0.52 = 104.6, 138.23 × 0.09 = 12.4.
.cn-activity-gauge<div> — Row (inline-flex, align center) with the rings on the left and the legend on the right. role="img" with an aria-label that reads every metric. Carries data-size.
.cn-activity-gauge__rings<svg> — The square svg, viewBox 0 0 100 100, holding a track and a value ring per metric.
.cn-activity-gauge__ring<circle> — One ring. data-ring="1|2|3" sets the color (brand.600 / 400 / 200, outside in); data-track marks the grey background ring. Value rings set stroke-dasharray (circumference) and stroke-dashoffset inline.
.cn-activity-gauge__legend<ul> — Stacked list of metrics next to the rings, gap space.2, no bullets.
.cn-activity-gauge__legend-item<li> — One row: swatch, label, value (label with data-value).
.cn-activity-gauge__swatch<span> — 8px round dot in the ring color (data-ring).
.cn-activity-gauge__label<span> — body-sm text: the metric name in fg-muted; with data-value, the number pushed to the end in fg-default, tabular.
Props
prop
values (default outlined)
meaning
size
smmdlg
Ring box 96 / 128 / 160px. sm for a stat tile, md for a dashboard card, lg for a page section with a heading.
Rules
Two or three rings, never one (ProgressCircle) or four. Order metrics by importance: the first is the outer ring in the strongest tint.
Geometry is fixed: viewBox 0 0 100 100, stroke 8, radii 42 / 32 / 22 with circumferences 263.89 / 201.06 / 138.23. Each value ring sets stroke-dasharray to its circumference and stroke-dashoffset = circumference × (1 − value / 100) inline.
Every value ring has a track ring of the same radius behind it (data-track) so an empty metric still reads as a ring.
Colors are the three brand tints only; the rings compare shares of goals, not statuses. Use a bar chart when the metrics need distinct hues.
The legend is mandatory and lists the metrics in ring order, each with its value; the number may be a count ("64 GB of 100") when the percentage alone is unclear.
Values are capped at 100%; show "100%" and say the goal was exceeded in the caption or the card description.
One gauge per card. sm in stat tiles, md in dashboard cards, lg in a page section.
Accessibility
The root is role="img" with an aria-label that reads every metric and value in ring order; the svg is aria-hidden.
The legend repeats the same information as visible text, so the rings are never the only source.
Swatches are decorative; the pairing between ring and legend is also carried by order (outer to inner, top to bottom).
Data display
BadgeGroup .cn-badge-groupedit
A pill that holds a small inner badge plus a message and an arrow: the "badge group" used for announcements above hero titles ("New feature · Check out the team dashboard →").
Use: Announcements, release notes and "what is new" links above a page or hero title. One per page. Not for status in tables (use Badge).
light = tinted outer pill with white inner badge; modern = white outer pill with grey ring and a tinted inner badge.
size
smmdlg
Outer pill 24 / 28 / 32px; text 12 / 14 / 14.
align
leadingtrailing
leading = inner badge first; trailing = inner badge after the message.
States
state
selector
meaning
hover
:hover
Slightly stronger fill; arrow moves 2px. (native)
focus
:focus-visible
Focus ring. (native)
Rules
One badge group per page, placed above the hero or page title with 16–24px below it.
Inner badge: 1–2 words; label: one short sentence; always ends with the arrow when it links.
the system equivalent: badge group (light / modern; leading / trailing; sm–lg).
Accessibility
When it navigates it is an <a href>; otherwise a <span> without hover affordance.
The inner badge text is part of the link name; do not hide it.
Data display
Message .cn-messageedit
Chat messages in a thread: incoming bubbles on the left in bg-subtle, outgoing on the right in the action color, each with a name and time line, a 32px avatar, optional file or image attachments, a three-dot typing indicator, centered date chips and a composer with a toolbar and a primary Send.
Use: Use for two-way conversations in real time: support chat, a thread with a teammate, the transcript of an agent conversation. For a comment stream on a document use ActivityFeed; for a timeline of events use Timeline; for one-off notifications use Notification.
Thread with an attachment and typingReceived on the left with an avatar, sent on the right in the action color. The typing indicator is an incoming bubble with three bouncing dots.
Today
MC
Maya Chen
Morning! Did the Q3 launch plan land in your inbox?
You
It did — reading it now. Two questions about the pricing page.
Perfect, thanks. I will leave comments before lunch.
MC
Anatomy, props, states, rules
Anatomy
.cn-message<div> — The thread (role="log"): a flex column of items and date dividers with a 16px gap. Width comes from the layout.
.cn-message__date-divider<div> · optional — Centered chip that separates days ("Today", "Sep 9"). role="separator".
.cn-message__item<div> — One message: avatar + content in a row. data-direction="in" (received, left) or "out" (sent, right, mirrored).
.cn-message__avatar<span> · optional — 32px circle with initials (24px in compact), aligned to the bottom of the bubble. Only on incoming messages; role="img" + aria-label.
.cn-message__content<div> — Column with the meta line and the bubble, aligned to the start (in) or the end (out).
.cn-message__meta<div> — Name and time on one line above the bubble.
.cn-message__time<time> — Sent time, body-xs fg-subtle, a <time datetime>.
.cn-message__bubble<div> — The message body, max 70% of the thread width, 10/14px padding, 16/24 text, radius 8 with the corner nearest the sender squared. Holds text and optional attachments.
.cn-message__attachment<a> · optional — A file card (icon + name + meta) or, with data-kind="image", a picture, inside the bubble. Links to the file.
.cn-message__attachment-icon<span> · optional — 32px square with a 16px file icon.
.cn-message__attachment-meta<span> · optional — Size or type under the name, body-xs, muted.
.cn-message__typing<div> · optional — Three animated dots in an incoming-style bubble. role="status" with "{Name} is typing".
.cn-message__dot<span> · optional — One 6px dot of the typing indicator; the three dots bounce in sequence.
.cn-message__composer<div> · optional — The reply box: hairline border, radius card, focus ring on :focus-within. Holds the textarea and the toolbar.
.cn-message__composer-input<textarea> · optional — Bare <textarea>, two rows, no border; grows with content in the app.
.cn-message__composer-toolbar<div> · optional — Bottom row of the composer: tool icon buttons on the left, the primary sm Send on the right.
.cn-message__composer-tools<div> · optional — The group of ghost IconButtons (attach, emoji, voice) that fills the toolbar's left side.
Props
prop
values (default outlined)
meaning
variant
threadcomposerbubble
thread = the full conversation column with dividers, messages, typing and optionally a composer at the end. composer = the root holds only the reply box (a chat that lives elsewhere, a "quick reply" footer). bubble = the root holds a single message, for embedding one quoted message in another surface.
density
defaultcompact
default = 32px avatars, 16/24 text, 16px between messages (full-page chat). compact = 24px avatars, 14px text, 8px between messages, for side panels and support widgets.
States
state
selector
meaning
incoming
.cn-message__item[data-direction="in"]
A received message (on the item): avatar on the left, bg-subtle bubble with the top-left corner squared. Styled in extraCss. (data-direction="in" on .cn-message__item)
outgoing
.cn-message__item[data-direction="out"]
A sent message (on the item): mirrored to the right, action-colored bubble with the top-right corner squared, no avatar. Styled in extraCss. (data-direction="out" on .cn-message__item)
composerFocus
.cn-message__composer:focus-within
Typing in the composer: the border turns action-colored and the 3px ring appears (extraCss). (native :focus-within on .cn-message__composer)
composerDisabled
.cn-message__composer[data-disabled]
The conversation is closed or read-only: grey fill, muted text, textarea disabled (extraCss). (data-disabled on .cn-message__composer plus disabled on the textarea)
Rules
Incoming on the left with an avatar, outgoing on the right without one. Never color-code people; direction is the only distinction.
Bubbles are at most 70% of the thread width and wrap text; never a fixed width, never a scrollbar inside a bubble.
Every message shows the sender and a time. Group consecutive messages from the same sender within 5 minutes under one meta line.
Date dividers between days only ("Today", "Yesterday", "Sep 9"); never a divider between every message.
Attachments live inside the bubble: a file card for documents, a picture (max 320px wide, radius md) for images. One attachment per bubble; more than one becomes a list.
The typing indicator shows for one person at a time and disappears after 5 seconds of silence; it is never shown for the current user.
The composer is the only bordered surface in the thread; tools are ghost IconButtons and Send is a primary sm Button. Enter sends, Shift+Enter breaks the line.
Compact density only in side panels and widgets narrower than 480px.
Long threads load older messages above with a "Load earlier messages" link Button; keep the newest message and the composer in view.
Accessibility
Root is role="log" with an aria-label naming the conversation; new messages are appended so screen readers announce them politely.
Each message exposes sender and time as text in DOM order before the body; the direction mirroring is CSS-only.
Avatars with initials use role="img" + aria-label; sent messages have no avatar and say "You" in the name.
The typing indicator is role="status" with an aria-label such as "Maya Chen is typing"; the dots are decorative.
The composer textarea has an accessible name, tool buttons have aria-labels, and the Send button is a real <button>; Escape clears a draft only after a confirm.
Data display
MetricGroup .cn-metric-groupedit
The metric-card family as one component: a responsive grid of surface cards with a label, a heading-lg tabular value, a soft success/danger trend pill with a 12px arrow and a "vs last month" note. Cards can add a featured icon, a 64px sparkline, a ⋯ menu or a "View report" link under a divider, or a progress bar; the list variant stacks the same items as rows in one card.
Use: Use at the top of a dashboard or report page to summarize 2–4 numbers with their change. For a single KPI use Stat; for a chart with axes use ChartFrame; for counts in navigation use Counter.
Simple, three columnsComponent previewLabel and value only. The unit is part of the value string.
.cn-metric-group<div> — The grid (role="group", aria-label): 24px gaps, 2–4 equal columns that collapse to two below the lg breakpoint and one below md.
.cn-metric-group__item<div> — One metric card: bg-surface, hairline, radius card, 20px padding, shadow-xs; a flex column with 16px gaps.
.cn-metric-group__header<div> — Top row: optional featured icon, the label, and the optional ⋯ actions pushed right.
.cn-metric-group__icon<span> · optional — Wrapper for a 40px FeaturedIcon (light theme) before the label.
.cn-metric-group__label<span> — What is measured, label-sm in fg-default ("Monthly recurring revenue").
.cn-metric-group__actions<div> · optional — Right-aligned actions: a ghost sm IconButton (⋯) in the header, or a link-color sm "View report" Button under the divider.
.cn-metric-group__body<div> — The value on the left and, when present, the sparkline on the right, bottom-aligned.
.cn-metric-group__value<span> — The number in heading-lg semibold with tabular figures; the unit is part of the string ("$48,210", "1.9%").
.cn-metric-group__delta<div> · optional — Row under the value: the trend pill and its label.
.cn-metric-group__trend<span> · optional — Soft pill: success for up, danger for down, neutral for flat (data-trend); add data-sentiment="good|bad" when the direction and the meaning disagree.
simple = label and value only. with-trend = adds the trend pill and period note (the default dashboard card). with-icon = a 40px FeaturedIcon before the label for a small set of very different metrics. with-chart = a sparkline next to the value for metrics with a shape over time. with-actions = a ⋯ menu in the header or a "View report" link under a divider. with-progress = a Progress bar for used-of-limit metrics (seats, storage). list = the same items as rows inside one card, for side panels and narrow columns.
columns
234
Number of equal columns on wide screens: 2 for two hero numbers or cards with charts, 3 by default, 4 for a dense KPI row. Below the lg breakpoint 3 and 4 become 2; below md everything is one column. Ignored by the list variant.
States
state
selector
meaning
up
.cn-metric-group__trend[data-trend="up"]
The value increased (on the trend pill; extraCss): success tint, up arrow. (data-trend="up" on .cn-metric-group__trend)
down
.cn-metric-group__trend[data-trend="down"]
The value decreased (extraCss): danger tint, down arrow. (data-trend="down" on .cn-metric-group__trend)
flat
.cn-metric-group__trend[data-trend="flat"]
No meaningful change (extraCss): neutral tint, a dash. (data-trend="flat" on .cn-metric-group__trend)
sentiment
.cn-metric-group__trend[data-sentiment]
When direction and meaning disagree (churn going down is good): data-sentiment="good|bad" overrides the tint while the arrow keeps the direction (extraCss). (data-sentiment="good" or "bad" on .cn-metric-group__trend)
Rules
Two to four metrics per group. A fifth number belongs in a table or a second group with its own heading.
Labels name the metric in sentence case ("Monthly recurring revenue"), never the value type; values carry their unit in the string and use tabular figures.
Format numbers for the locale with at most four significant characters plus unit ("$48.2k" beats "$48,210.37" in a card).
data-trend is the direction of change; when up is bad or down is good (churn, cost, response time), add data-sentiment so the tint tells the truth while the arrow keeps the direction.
The period note is short and always present with a trend ("vs last month"); a trend without a period is meaningless.
One variant per group: every card has the same parts, aligned on the same rows. Never mix a sparkline card with a plain one.
Sparklines are decorative and unlabeled; if the shape matters enough to read, use ChartFrame.
Actions are one ⋯ menu in the header or one "View report" link under the divider, never both.
Use list in columns narrower than 420px or inside drawers; on a page use the grid.
Accessibility
The root is role="group" with an aria-label ("Key metrics"); each card reads in order: label, value, trend, note.
Trend arrows are aria-hidden; the trend text includes the sign or the word ("+12%", "0.4 pts") and the label gives the period, so color is never the only signal.
Sparklines are aria-hidden; when a chart carries information, give it a text alternative or use ChartFrame.
The ⋯ button has aria-label "More actions" and aria-haspopup="menu"; "View report" is a real link or button with the metric name available via aria-label when several cards share the text.
Progress bars use role="progressbar" with aria-valuenow/min/max and a visible value.
Data display
Rating .cn-ratingedit
Read-only star rating: five stars (filled amber, half, or empty in the hairline color), an optional numeric value and a muted count. The badge variant packs five small stars and the value into a hairline pill; inline shows one star with the number for dense rows.
Use: Display reviews, satisfaction scores and quality grades next to a product, a vendor or a testimonial. It is not an input: for collecting a rating use a Radio group of stars. Do not use stars for anything that is not a rating (priority, importance).
Stars with value and countFour filled stars, one half star, the number and the count.
Testimonial headerBadge above the quote, attribution in muted text below.
5.0
“Lumen found the buyers we had been chasing for two years in one afternoon.”
Elena Rossi, Head of Procurement
HTML
<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);max-width:420px"><div class="cn-rating" data-variant="badge" data-size="md" role="img" aria-label="5 out of 5 stars"><span class="cn-rating__badge"><svg class="cn-rating__star" data-filled="true" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z"/></svg><svg class="cn-rating__star" data-filled="true" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z"/></svg><svg class="cn-rating__star" data-filled="true" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z"/></svg><svg class="cn-rating__star" data-filled="true" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z"/></svg><svg class="cn-rating__star" data-filled="true" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z"/></svg><span class="cn-rating__value">5.0</span></span></div><p class="cn-text-body-md" style="margin:0">“Lumen found the buyers we had been chasing for two years in one afternoon.”</p><p class="cn-text-body-sm" style="margin:0;color:var(--cn-color-fg-muted)">Elena Rossi, Head of Procurement</p></div>
Matrix
variant ↓ / size →
sm
md
stars
4.5(128 reviews)
4.5(128 reviews)
badge
4.5(128 reviews)
4.5(128 reviews)
inline
4.5(128 reviews)
4.5(128 reviews)
Anatomy, props, states, rules
Anatomy
.cn-rating<div> — Inline row (flex, align center, gap space.1). role="img" with aria-label "4.5 out of 5 stars, 128 reviews". Carries data-variant and data-size.
.cn-rating__star<svg> — One star. data-filled="true" paints it amber, "false" the hairline color, "half" splits it with a clip on the right half. 16px (sm) or 20px (md); 12px inside the badge.
.cn-rating__value<span> · optional — The number ("4.5"), label-sm, tabular. Follows the stars with a small gap.
.cn-rating__badge<span> · optional — Hairline pill (surface fill, radius full, padding-inline space.2) holding five 12px stars and the value. Used by the badge variant.
Props
prop
values (default outlined)
meaning
variant
starsbadgeinline
stars = five stars with an optional value and count, for product pages and vendor cards; badge = a pill with five small stars and the value, followed by muted text, for testimonials and marketing proof lines; inline = a single star plus the value (and count), for table cells, list rows and compact cards.
size
smmd
md = 20px stars with label-sm value (cards, headers); sm = 16px stars with label-xs value (rows, dense lists). The badge always uses 12px stars.
Rules
Always five stars. Fill from the left; a fraction from .25 to .75 is a half star, never a partial fill at other widths.
Filled stars are amber, empty stars the hairline color. Never recolor stars by brand or by score; the number carries the nuance.
Show the value with one decimal ("4.5", "4.0") and the count with a thousands separator ("1,240 reviews"). Round the value, never the count.
stars on product and vendor pages; inline in tables, lists and compact cards; badge only on marketing and testimonial blocks.
With fewer than 5 reviews show the count and no value; with 0 show empty stars and "No ratings yet".
Rating is display only. Collecting a rating is a form control (Radio group of stars) with its own focus and keyboard behavior.
Sizes match the surrounding text: sm next to body-sm, md next to body-md and headings.
Accessibility
The root is role="img" with aria-label that states the score and the count ("4.5 out of 5 stars, 128 reviews"); the stars are aria-hidden.
The visible value and count repeat the aria-label content, so sighted and screen-reader users get the same information.
Color is not the only cue: filled and empty stars differ in luminance and the number is always available nearby or in the label.
Data display
TreeView .cn-tree-viewedit
A hierarchy of rows that expand and collapse: a chevron that rotates when open, a 20px folder or file icon, the label and an optional count, with nested groups indented 24px and an optional guide line. Rows are 36px (32px small), hover in bg-subtle, the selected row keeps the fill with a semibold label.
Use: Use for file systems, folder structures, nested categories and org charts where depth matters and people open branches on demand. For a flat list use List; for navigation with two levels use SidebarNav; for a table with expandable rows use Table.
Three levels, one selectedOpen folders rotate the chevron; the leaf rows hide it. "landing-page.fig" is selected; "Finance" is collapsed with a count.
.cn-tree-view<ul> — The tree (role="tree", aria-label). A list; nested groups are <ul role="group"> after their parent row inside a <li role="none">.
.cn-tree-view__item<div> — One row (role="treeitem", aria-level, aria-expanded when it has children, aria-selected, roving tabindex). 36px tall, 8px horizontal padding, radius md, body-md.
.cn-tree-view__toggle<span> — 16px chevron on the left; rotates 90° when the row is aria-expanded="true"; invisible on leaf rows so labels stay aligned. aria-hidden.
.cn-tree-view__icon<span> — 20px folder or file icon in fg-muted.
.cn-tree-view__label<span> — The name, truncated with an ellipsis; semibold when the row is selected.
.cn-tree-view__badge<span> · optional — Optional count pill at the end of the row (children, unread), label-xs in bg-subtle.
.cn-tree-view__group<ul> · optional — The nested list of children (role="group"), indented by 24px; in the with-lines variant it draws a 1px guide under the parent chevron.
Props
prop
values (default outlined)
meaning
size
smmd
md = 36px rows with body-md text and 20px icons (file browsers, settings trees). sm = 32px rows with body-sm text and 16px icons for side panels and pickers.
variant
defaultwith-lineswith-checkbox
default = indentation only. with-lines = a 1px guide line under each open parent so deep trees stay readable. with-checkbox = a sm Checkbox before the icon on every row for multi-select (moving files, choosing folders to sync); parents show indeterminate when only some children are checked.
States
state
selector
meaning
hover
.cn-tree-view__item:hover
Pointer over a row (on the item; extraCss): bg-subtle fill. (native :hover on .cn-tree-view__item)
selected
.cn-tree-view__item[aria-selected="true"]
The chosen row (on the item; extraCss): bg-subtle fill, ink text, semibold label, ink icon. (aria-selected="true" on .cn-tree-view__item)
expanded
.cn-tree-view__item[aria-expanded="true"]
An open parent (on the item; extraCss): the chevron rotates 90°. aria-expanded="false" hides the following group. (aria-expanded="true|false" on .cn-tree-view__item)
focus
.cn-tree-view__item:focus-visible
Keyboard focus on a row shows the 3px ring (extraCss). (native :focus-visible on .cn-tree-view__item)
disabled
.cn-tree-view__item[aria-disabled="true"]
A row that cannot be chosen (no permission): disabled text, no hover (extraCss). (aria-disabled="true" on .cn-tree-view__item)
Rules
Rows show one name each, truncated with an ellipsis; the full name lives in a title or Tooltip. Never wrap a row onto two lines.
Folders before files, both alphabetical, unless the user chose another sort. Do not mix sort orders between levels.
Only parents get a chevron; leaves keep an invisible 16px slot so all labels at one level align.
Exactly one selected row in single-select trees; the selected row is the one whose content is shown next to the tree.
Counts in the badge are for children or unread items and update live; never use the badge for status words.
Use with-lines when trees go deeper than three levels; at two levels the indentation is enough.
with-checkbox is for choosing many nodes at once (move, sync, export); the row click still selects, only the box toggles.
Deep trees (more than five levels) are a design smell: flatten with breadcrumbs or a search instead.
sm inside side panels and pickers; md in a file browser or settings page where the tree is the main content.
Accessibility
Root has role="tree" and an aria-label; rows are role="treeitem" with aria-level, aria-expanded on parents and aria-selected; child lists are role="group" placed right after their parent row. Add aria-owns on the parent when your framework needs explicit ownership.
Roving tabindex: one row is tabbable; Up/Down move, Right expands or moves into children, Left collapses or moves to the parent, Home/End jump, Enter activates, typing jumps to a matching name.
The chevron and icons are aria-hidden; the label is the accessible name. Counts are read as text after the name.
In with-checkbox each Checkbox has an aria-label "Select {name}"; Space toggles the box while the row keeps its own selection.
Selection and hover are conveyed by fill and weight, never by color alone; the disabled state uses aria-disabled so the row is still announced.
Feedback
Alert .cn-alertedit
Callout with a featured icon (a 20px line icon inside two faint concentric rings), a semibold title, a one-line description, optional link actions and a close button. 16px padding, 12px radius; tinted (soft) or white (outline); in the flow of the page, floating with a shadow, or as a full-width bar.
Use: Use for messages tied to the content on screen: a failed sync, a plan limit, a confirmation after a form saves, a release note above a list. Not for transient feedback (use Toast), not for one-line page-wide notices (use Banner), and not for decisions that block the flow (use Dialog).
Default (outline, neutral, inline)The "default" alert: white surface, gray featured icon, two link actions (gray then brand), close button top-right.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
HTML
<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="inline" role="status" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7v4M8 5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View changes</span></button></div></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Brand (soft)
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
HTML
<div class="cn-alert" data-tone="brand" data-variant="soft" data-layout="inline" role="status" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View changes</span></button></div></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Info (soft)
A new export format is available
You can now download reports as an Excel workbook with one sheet per team.
HTML
<div class="cn-alert" data-tone="info" data-variant="soft" data-layout="inline" role="status" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7v4M8 5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">A new export format is available</div><p class="cn-alert__description">You can now download reports as an Excel workbook with one sheet per team.</p></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Success (soft)
Successfully updated profile
Your changes have been saved and will appear in the team directory within a few minutes.
HTML
<div class="cn-alert" data-tone="success" data-variant="soft" data-layout="inline" role="status" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">Successfully updated profile</div><p class="cn-alert__description">Your changes have been saved and will appear in the team directory within a few minutes.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View profile</span></button></div></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Warning (soft)
Your trial ends in 3 days
Add a payment method to keep your projects and team members after 14 Sep.
HTML
<div class="cn-alert" data-tone="warning" data-variant="soft" data-layout="inline" role="alert" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5l6 11H2l6-11zM8 7v3M8 11.5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">Your trial ends in 3 days</div><p class="cn-alert__description">Add a payment method to keep your projects and team members after 14 Sep.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Upgrade plan</span></button></div></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Danger (soft)Danger alerts have no close button while the problem is open; they leave when it is fixed.
There was a problem with that action
We couldn’t save your changes. Check your connection and try again.
HTML
<div class="cn-alert" data-tone="danger" data-variant="soft" data-layout="inline" role="alert" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5l6 11H2l6-11zM8 7v3M8 11.5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">There was a problem with that action</div><p class="cn-alert__description">We couldn’t save your changes. Check your connection and try again.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Try again</span></button></div></div></div>
Floating (shadow-lg)Same card with shadow-lg, for alerts rendered over the page (bottom of the viewport, above a table).
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
HTML
<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="floating" role="status" style="max-width:640px"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7v4M8 5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View changes</span></button></div></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Full-width barEdge to edge under the topbar: square corners, bottom hairline only, actions on the right as a direct child of the root.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
HTML
<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="full-width" role="status"><span class="cn-alert__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7v4M8 5v.5"/></svg></span><div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p></div><div class="cn-alert__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View changes</span></button></div><button type="button" class="cn-alert__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Matrix
variant ↓ / tone →
neutral
brand
info
success
warning
danger
soft
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
outline
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
We’ve just released a new feature
Check out the all new dashboard view. Pages now load faster.
Anatomy, props, states, rules
Anatomy
.cn-alert<div> — The callout surface. role="alert" for danger/warning that appear dynamically, role="status" otherwise. Carries data-tone, data-variant, data-layout.
.cn-alert__icon<span> — Featured icon, "outline" style: a 20px line icon (ICON.info / ICON.check / ICON.warning inside the span) in the tone color with two concentric 2px rings at 30% and 10% opacity (28px and 38px). aria-hidden.
.cn-alert__content<div> — Text column: title, description, actions. Flexes to fill; min-width 0 so long text wraps.
.cn-alert__title<div> — text-sm semibold (label-md), one line. Says what happened ("We’ve just released a new feature"), not "Error".
.cn-alert__description<p> · optional — text-sm muted (body-md, fg-muted), one or two sentences under the title. Says what to do next.
.cn-alert__actions<div> · optional — Row of link Buttons (size sm), 12px apart: a quiet one first ("Dismiss", gray) then the one that does something ("View changes", brand). At most two. Inside the content column, or a direct child of the root in the full-width layout (then it sits on the right).
.cn-alert__dismiss<button> · optional — Close button in the top-right corner: 36px hit area, 20px X icon, fg-subtle, 8px from the edges. Requires aria-label="Dismiss".
Props
prop
values (default outlined)
meaning
tone
neutralbrandinfosuccesswarningdanger
Meaning. neutral = context or a tip (gray icon; "default"/"gray"); brand = product news and upsell (brand icon, brand tint); info = something to know, no action needed (blue); success = a completed action; warning = needs attention soon, nothing is broken; danger = failed or blocked, the user must act ("error").
variant
softoutline
soft = tinted background (tone-50) with the tone border; outline = white surface with the neutral hairline and only the icon in the tone color ("default" alert; quieter, for lists of alerts or inside cards).
layout
inlinefloatingfull-width
inline = sits in the content flow above the thing it describes, no shadow (default); floating = the same card with shadow-lg, for alerts rendered over the page (bottom of the viewport, over a table); full-width = edge-to-edge bar with square corners, a bottom hairline and the actions on the right ("full-width" alert), placed directly under the topbar or at the top of a section.
States
state
selector
meaning
dismissHover
.cn-alert__dismiss:hover
Pointer over the close button: subtle fill, icon one step darker. Styled in extraCss because it lives on a child. (native :hover on .cn-alert__dismiss)
dismissFocus
.cn-alert__dismiss:focus-visible
Keyboard focus on the close button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-alert__dismiss)
Rules
Title says what happened in plain words ("We’ve just released a new feature"); the description says what to do next. Never "Error" or "Warning" as the title.
Keep it to one title plus at most two sentences. If it needs more, link to a page.
At most two actions, size sm, variant link: the quiet one first ("Dismiss", rendered gray), the useful one last ("View changes", brand). Never a primary or danger Button inside an Alert.
Text stays neutral in every tone (title fg-default, description fg-muted). Only the icon, the border and the tint carry the color, so a soft danger alert is still readable.
inline alerts sit at the top of the region they describe (above the form, inside the card); floating alerts are the same card with shadow-lg over the page; full-width bars go directly under the topbar or at the top of a section, never inside a card.
Use soft by default. Use outline (the "default" alert) when several alerts stack, when the alert is inside another tinted surface, or when the message is neutral news.
Only neutral, brand, info and success alerts are dismissible without resolving anything. Danger alerts disappear when the problem is fixed, not when the user closes them.
Icon is required and comes from the tone: neutral/info/brand → ICON.info (or ICON.spark for brand news), success → ICON.check, warning/danger → ICON.warning. Wrap it in the icon span; the rings are drawn by CSS.
Do not animate alerts in or out; they appear with the content.
Accessibility
role="alert" for warning/danger alerts inserted after load (assistive tech announces them immediately); role="status" for neutral/brand/info/success.
The featured icon is decorative (aria-hidden on the span); the title carries the meaning, so color is never the only signal.
The close button needs aria-label="Dismiss" and is the last focusable element inside the alert.
Actions are real <button> or <a> elements in reading order after the description.
Feedback
Banner .cn-banneredit
Full-width strip at the top of the page or a section: one line of text, an optional icon, one action and a dismiss. Square corners, no shadow, a hairline at the bottom; it reads as part of the chrome, not as a card.
Use: Use for one notice that concerns the whole page or account: maintenance tonight, trial ending, a product announcement (tone accent), an outage (tone danger). Not for feedback on a specific element (use Alert) and not for transient confirmations (use Toast).
Neutral (soft)
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
The Gmail connection for sales@verolabs.com expires tomorrow. Reconnect to avoid a gap in replies.
HTML
<div class="cn-banner" data-tone="warning" data-variant="soft" role="status"><svg class="cn-banner__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5l6 11H2l6-11zM8 7v3M8 11.5v.5"/></svg><div class="cn-banner__content">The Gmail connection for <strong>sales@verolabs.com</strong> expires tomorrow. Reconnect to avoid a gap in replies.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Reconnect</span></button></div></div>
Accent (announcement)
New: agents now read replies in Japanese and German and draft answers in the buyer’s language.
HTML
<div class="cn-banner" data-tone="accent" data-variant="soft" role="status"><svg class="cn-banner__icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg><div class="cn-banner__content"><strong>New:</strong> agents now read replies in Japanese and German and draft answers in the buyer’s language.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">See what changed</span></button></div><button type="button" class="cn-banner__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Danger (solid)solid is reserved for danger and info notices that must not be missed. No dismiss while the incident is open.
Email delivery is degraded since 09:12 UTC. Sent messages may arrive late; nothing is lost.
HTML
<div class="cn-banner" data-tone="danger" data-variant="solid" role="alert"><svg class="cn-banner__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5l6 11H2l6-11zM8 7v3M8 11.5v.5"/></svg><div class="cn-banner__content">Email delivery is degraded since 09:12 UTC. Sent messages may arrive late; nothing is lost.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Status page</span></button></div></div>
Matrix
variant ↓ / tone →
neutral
info
warning
danger
accent
soft
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
solid
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Scheduled maintenance on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.
Anatomy, props, states, rules
Anatomy
.cn-banner<div> — The strip. Spans the full width of its container; role="status" (or "alert" for danger).
.cn-banner__icon<svg> · optional — 16px icon in the current text color. Decorative.
.cn-banner__content<div> — The message, one sentence, body-sm. Flexes to fill; may contain a <strong> lead.
.cn-banner__actions<div> · optional — One link or outline Button (size sm) on the right.
.cn-banner__dismiss<button> · optional — 24px ghost icon button with an X at the far end. aria-label="Dismiss".
Props
prop
values (default outlined)
meaning
tone
neutralinfowarningdangeraccent
neutral = housekeeping notice; info = something to know; warning = act before a deadline; danger = something is broken now; accent = brand announcement (new feature, launch) in the accent wash — never for status.
variant
softsolid
soft = tinted background with dark tone text (default); solid = filled tone background with white text, reserved for danger (outage) and info (maintenance) when the notice must not be missed.
States
state
selector
meaning
dismissHover
.cn-banner__dismiss:hover
Pointer over the dismiss button: faint current-color wash. Styled in extraCss because it lives on a child. (native :hover on .cn-banner__dismiss)
dismissFocus
.cn-banner__dismiss:focus-visible
Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-banner__dismiss)
sticky
[data-sticky]
Sticks under the topbar while the page scrolls. Position only; styled in extraCss so examples stay static. (data-sticky attribute on the root)
Rules
One banner per page at most. If a second notice appears, the more urgent one wins and the other waits.
One sentence, ideally under 90 characters, with an optional <strong> lead. No title, no second line.
One action at most (link or outline, size sm) plus the dismiss. Never a primary Button in a banner.
No radius, no shadow, no margin: the banner touches the edges of its container and sits directly under the topbar.
Use data-sticky only when the notice must stay visible while scrolling (an open incident). Sticky banners are never accent.
variant="solid" only for tone danger or info. Never solid accent, warning or neutral.
Danger banners for open incidents are not dismissible; they leave when the incident closes.
Accent tone is for announcements only. It never signals a status or an error.
Accessibility
role="status" by default; role="alert" for danger banners inserted after page load.
The banner is inside the header landmark or the first child of <main>, so screen readers meet it before the page content.
The dismiss button needs aria-label="Dismiss"; the action must be a real <button> or <a href>.
Solid variants keep AA contrast: white text on info/danger, ink text on warning.
Feedback
Toast .cn-toastedit
Short-lived notification panel that slides in at the bottom-right corner: a raised white card with an icon, a title, one line of detail and at most one action. The component styles the panel; the fixed region that stacks toasts is a separate class.
Use: Use to confirm something the user just did ("Draft saved", "Invite sent") or to report a background event that needs no decision. Not for errors the user must act on (use Alert in place, or Dialog) and not for information that must persist (use Banner).
.cn-toast__icon<svg> · optional — 16px icon in the tone color, top-aligned with the title. Decorative.
.cn-toast__content<div> — Title + description + action. Flexes to fill.
.cn-toast__title<div> — label-md, one line: what happened ("Invite sent").
.cn-toast__description<p> · optional — body-sm muted, one line of detail (who, when, how many).
.cn-toast__action<div> · optional — One link or ghost Button (size sm) under the description: "Undo", "View".
.cn-toast__dismiss<button> · optional — 24px ghost icon button with an X in the top-right corner. aria-label="Dismiss".
Props
prop
values (default outlined)
meaning
tone
neutralsuccesswarningdangerinfo
Only the icon color changes; the panel stays white. neutral = plain confirmation; success = a completed action; warning = something to look at later; danger = a background failure that needs no immediate decision; info = a heads-up.
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Removed from view (display none) after the auto-dismiss timer or the dismiss button. (data-state="closed")
dismissHover
.cn-toast__dismiss:hover
Pointer over the dismiss button: subtle fill. Styled in extraCss because it lives on a child. (native :hover on .cn-toast__dismiss)
dismissFocus
.cn-toast__dismiss:focus-visible
Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-toast__dismiss)
Rules
Toasts auto-dismiss after 5 seconds (8 seconds when they carry an action). Hovering pauses the timer.
At most 3 toasts visible; the oldest leaves when a fourth arrives.
Never use a toast for an error that needs a decision. If the user must act, show an Alert next to the thing or a Dialog.
Title is 2–5 words in past tense ("Invite sent"); description is one line with the concrete object, count or time.
At most one action, variant link or ghost, size sm. "Undo" is the most valuable action a toast can offer.
Render toasts inside .cn-toast-region (fixed, bottom-right, z toast). The panel itself is never position: fixed.
The panel stays white in every tone; only the icon changes color. No tinted toasts.
Enter animation is translateY(8px) → 0 with opacity, 260ms; leaving is opacity only. Both disappear under prefers-reduced-motion.
Accessibility
role="status" on the panel (polite); use aria-live="assertive" on the region only for danger toasts.
The region is aria-label="Notifications"; toasts never steal focus when they appear.
The dismiss button has aria-label="Dismiss"; Escape dismisses the most recent toast when a toast has focus.
Pause the auto-dismiss timer while the toast or its action has focus or the pointer.
Feedback
Progress .cn-progressedit
Horizontal bar that shows how much of a known task is done: a muted track, a filled indicator whose width is the value, and an optional label/value line above. Indeterminate mode slides a short segment when the total is unknown.
Use: Use for tasks with a measurable share: an import, seats used of a plan, steps of an onboarding. Use the indeterminate state only briefly, while the total is being computed. For a wait without a value use Spinner; for loading content use Skeleton.
.cn-progress__track<div> — The full-width muted bar, radius full, clips the indicator.
.cn-progress__indicator<div> — The filled part. Width comes from an inline style (style="width:42%") and transitions smoothly.
Props
prop
values (default outlined)
meaning
size
smmdlg
Track height. sm = 2px for inside table rows and cards; md = 4px default; lg = 8px for a page-level task (import, onboarding).
tone
actionaccentsuccesswarningdanger
Fill color. action = default; accent = an agent is working on it; success = completed; warning = near a limit (≥ 80%); danger = over the limit or failed.
States
state
selector
meaning
indeterminate
[data-state="indeterminate"]
Total unknown: a 40%-wide segment slides across the track in a loop. No aria-valuenow; aria-busy="true". (data-state="indeterminate" on the root (omit the inline width))
Rules
Width comes from the layout; the indicator width is the only inline style allowed (style="width:42%").
Always show the value when the user can act on it (seats, quota). Show "Done" instead of 100% when the task is finished.
Tone follows the value, not the brand: switch to warning at ≥ 80% of a limit and to danger when the limit is exceeded or the task failed.
accent means an agent is doing the work. Do not use it for user-driven uploads.
Indeterminate for at most a few seconds; if the total stays unknown, replace it with a Spinner and a sentence.
Sizes: sm inside rows and cards, md by default, lg for one page-level task at a time.
Never animate the indicator on first paint; only transitions between updates (260ms).
Accessibility
role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax; use aria-valuetext when the number alone is unclear ("34 of 40 seats").
Name it with aria-labelledby pointing to the label, or aria-label when there is no visible label.
Indeterminate: omit aria-valuenow and set aria-busy="true" on the region being loaded.
Color is never the only signal: the value text or the label states warning/danger conditions.
Feedback
Spinner .cn-spinneredit
Small rotating ring for a wait without a known duration. A 2px current-color circle with one transparent quarter, spinning at 0.7s; it inherits the text color so it fits inside buttons, rows and empty states.
Use: Use next to a short sentence while something loads and no layout is known yet ("Searching 212 companies…"), or inside a control that is busy. For loading content whose shape is known use Skeleton; for a measurable task use Progress. Never use more than one spinner in view.
.cn-spinner<span> — The ring. role="status"; contains a visually hidden label (.cn-sr-only) so screen readers announce what is happening.
Props
prop
values (default outlined)
meaning
size
xssmmdlgxl
Diameter from the icon scale: xs 12px (inside badges), sm 14px (inside sm buttons and table rows), md 16px (default, inline with body text), lg 20px (empty states), xl 24px (page-level loading).
tone
currentmutedactionon-action
current = inherits the text color (default, inside buttons and rows); muted = subtle grey for quiet loading; action = the action color for a primary wait; on-action = the text color used on filled action surfaces.
Rules
One spinner in view at a time. Several loading areas share one spinner at the top of the region, or use Skeleton.
Pair it with a sentence that names what is happening ("Searching 212 companies…"); a lone spinner is only acceptable inside a control.
Do not show it for waits under 300ms; flashing spinners feel slower than nothing.
Inside a Button use data-loading; the Button already has its own spinner sized to the control.
Size follows the text it sits next to: sm with body-sm, md with body-md, lg/xl only in empty states and page-level loading.
Tone current by default; muted for background loading; action only for a primary wait the user asked for.
Never place a spinner inside a Toast or a Badge.
Accessibility
role="status" on the ring with a visually hidden text (.cn-sr-only) that says what is loading.
When the wait ends, update the same live region with the result ("38 companies found") so the change is announced.
The animation stops under prefers-reduced-motion (base.css); the static ring remains visible.
Do not put the spinner inside aria-hidden containers when it is the only feedback.
Feedback
Skeleton .cn-skeletonedit
Grey placeholder shapes that hold the layout while content loads: text lines, a circle, a rectangle or a card, with a slow shimmer sweeping across. Same size as the content they stand in for, so nothing jumps when it arrives.
Use: Use when the shape of the incoming content is known (a list, a card, a profile header) and the wait is over ~300ms. For a wait with no known layout use Spinner; for a measurable task use Progress. Never leave skeletons on screen after an error; replace them with an empty state or an Alert.
Text linesConsecutive text skeletons space themselves 8px apart. The last line is shorter.
.cn-skeleton<div> — One placeholder block. aria-hidden="true"; the region around it carries aria-busy. Size comes from the variant plus inline width/height.
Props
prop
values (default outlined)
meaning
variant
textcirclerectcard
text = one line, 1em tall, radius sm, full width (set width inline for the last line); circle = 40px avatar placeholder, radius full; rect = a box with radius md for images, inputs and buttons (size inline); card = radius xl, min-height 120px, for a whole card.
Rules
Match the real content: same widths, same line count, same avatar size. A skeleton that does not match the layout causes a jump and is worse than a spinner.
Show 3–5 rows for a list, never the whole page. Below the fold the real content can arrive silently.
Do not show skeletons for waits under 300ms, and keep them at least 500ms once shown to avoid flicker.
One shimmer rhythm for the whole page (1.4s); all skeletons animate in sync because they share one keyframe.
Vary text widths (100%, 60%, 80%); identical lines look like a table, not text.
Never put skeletons inside a Button, a Badge or a Toast.
Errors replace skeletons with an Alert or an empty state; they never stay on screen.
Accessibility
aria-hidden="true" on every skeleton; put aria-busy="true" on the region that is loading and remove it when content arrives.
Announce the loaded state through a live region only when the wait was long (more than a few seconds).
The shimmer stops under prefers-reduced-motion (base.css); the grey shapes remain.
Feedback
EmptyState .cn-empty-stateedit
What a region shows when it has nothing to show, in the reference layout: a modern gray featured icon (or a quiet illustration) above a short semibold title, one sentence of help capped at 352px and the actions that fill it. Centered, narrow, calm.
Use: Empty collections (no projects yet), empty filters and searches (no results), empty slots (no file uploaded) and drop zones. Not for errors (Alert) and not for loading (Skeleton).
No projects yet (default md)A 48px modern gray featured icon 20px above an 18px semibold title, 14px gray-600 text capped at 352px, two buttons 12px apart 32px below.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
HTML
<div class="cn-empty-state" data-size="md" data-variant="default"><div class="cn-empty-state__header"><span class="cn-featured-icon cn-empty-state__icon" data-theme="modern" data-tone="gray" data-size="lg" data-shape="square"><svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V3.5a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h2"/></svg></span></div><h3 class="cn-empty-state__title">No projects yet</h3><p class="cn-empty-state__description">Projects keep your team's work in one place. Create the first one or import from another tool.</p><div class="cn-empty-state__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Import</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg><span class="cn-button__label">New project</span></button></div></div>
IllustrationAn inline SVG illustration (documents) in the header, 24px above the title.
You're all caught up
Every task assigned to you is done. New work will show up here as soon as a teammate assigns it.
HTML
<div class="cn-empty-state" data-size="md" data-variant="illustration"><div class="cn-empty-state__header"><svg class="cn-empty-state__illustration" viewBox="0 0 152 120" aria-hidden="true"><ellipse cx="76" cy="108" rx="60" ry="6" fill="var(--cn-color-bg-muted)"/><rect x="38" y="30" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="46" y="22" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="54" y="14" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="66" y="30" width="36" height="6" rx="3" fill="var(--cn-color-bg-muted)"/><rect x="66" y="44" width="52" height="6" rx="3" fill="var(--cn-color-bg-subtle)"/><rect x="66" y="58" width="44" height="6" rx="3" fill="var(--cn-color-bg-subtle)"/><circle cx="110" cy="80" r="14" fill="var(--cn-color-bg-action-subtle)" stroke="var(--cn-color-bg-action)" stroke-width="1.5"/><path d="M104.5 80.5l3.5 3.5 7-7.5" fill="none" stroke="var(--cn-color-bg-action)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><h3 class="cn-empty-state__title">You're all caught up</h3><p class="cn-empty-state__description">Every task assigned to you is done. New work will show up here as soon as a teammate assigns it.</p><div class="cn-empty-state__actions"><button type="button" class="cn-button" data-variant="link-color" data-size="md"><span class="cn-button__label">View all tasks</span></button></div></div>
No results, with search (sm)The search field sits between the text and the actions so the query can be refined in place; 16px title.
No customers found
Your search “Osaka” did not match any customers. Try a different name or clear the filters.
Drop zone (dashed)Dashed gray-300 frame with a 40px modern icon; the button is the keyboard path.
Drop a CSV here
One customer per row, with at least a name and an email address.
HTML
<div class="cn-empty-state" data-size="md" data-variant="dashed" style="max-width:512px"><div class="cn-empty-state__header"><span class="cn-featured-icon cn-empty-state__icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square"><svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h3l1 2h3l1-2h3M2.5 9.5V4a1 1 0 011-1h9a1 1 0 011 1v5.5M2.5 9.5V12a1 1 0 001 1h9a1 1 0 001-1V9.5"/></svg></span></div><h3 class="cn-empty-state__title">Drop a CSV here</h3><p class="cn-empty-state__description">One customer per row, with at least a name and an email address.</p><div class="cn-empty-state__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Choose file</span></button></div></div>
Whole page (lg, card)56px icon, 20px title, 16px text; the white card frame when the empty state is the whole canvas region.
Nothing waiting for you
Your team is working on 12 tasks. You will see reviews and approvals here as soon as they need a decision.
HTML
<div class="cn-empty-state" data-size="lg" data-variant="card"><div class="cn-empty-state__header"><span class="cn-featured-icon cn-empty-state__icon" data-theme="modern" data-tone="gray" data-size="xl" data-shape="square"><svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h3l1 2h3l1-2h3M2.5 9.5V4a1 1 0 011-1h9a1 1 0 011 1v5.5M2.5 9.5V12a1 1 0 001 1h9a1 1 0 001-1V9.5"/></svg></span></div><h3 class="cn-empty-state__title">Nothing waiting for you</h3><p class="cn-empty-state__description">Your team is working on 12 tasks. You will see reviews and approvals here as soon as they need a decision.</p><div class="cn-empty-state__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">See what the team is doing</span></button></div></div>
Inline in a chart or table bodyThe bare 24px line icon for the smallest inline states, no header wrapper.
No results for “Osaka”
Try a wider region or clear the score filter.
HTML
<div class="cn-empty-state" data-size="sm" data-variant="default"><svg class="cn-empty-state__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg><h3 class="cn-empty-state__title">No results for “Osaka”</h3><p class="cn-empty-state__description">Try a wider region or clear the score filter.</p><div class="cn-empty-state__actions"><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">Clear filters</span></button></div></div>
Matrix
variant ↓ / size →
sm
md
lg
default
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
card
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
dashed
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
illustration
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
with-search
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
No projects yet
Projects keep your team's work in one place. Create the first one or import from another tool.
Anatomy, props, states, rules
Anatomy
.cn-empty-state<div> — Centered column, max width 512px, padding from size. Plain, card or dashed frame.
.cn-empty-state__header<div> · optional — Optional wrapper for the featured icon or illustration, 16 / 20 / 20px above the title (24 with an illustration).
.cn-empty-state__icon<span> · optional — A FeaturedIcon (theme modern, tone gray, 48px; 56px at lg) that also carries this class; or a bare 24px line icon in fg-subtle for the smallest inline states. Decorative.
.cn-empty-state__illustration<svg> · optional — An inline SVG illustration (≈152 × 120) drawn with token variables: documents, a cloud, a box, a credit card.
.cn-empty-state__title<h3> — 16 / 18 / 20px semibold in ink: what is empty, in plain words ("No projects yet").
.cn-empty-state__description<p> · optional — One sentence, 14px (16px at lg) fg-muted, max 352px: why it is empty or what will fill it.
.cn-empty-state__search<div> · optional — Optional search Input (max 352px) between the text and the actions for "no results" states, so the user can refine the query in place.
.cn-empty-state__actions<div> · optional — Row of one primary Button and optionally one secondary (outline) Button, 12px apart, 24 / 32 / 32px below the text.
Props
prop
values (default outlined)
meaning
size
smmdlg
sm = inline in a card, table body or chart (16px title, 14px text, 48px icon, 4px text gap); md = a page section (18px title, 14px text, 48px icon, 8px gap); lg = a whole empty page (20px title, 16px text, 56px icon).
variant
defaultcarddashedillustrationwith-search
default = no frame, a featured icon in the header (inside a Card, table or panel); card = white with a gray-200 ring, shadow-xs and radius 12 (standalone on the canvas); dashed = dashed gray-300 border, transparent, for drop zones and "create your first" slots; illustration = an inline SVG illustration in the header instead of the icon (24px below it); with-search = a search field under the text for "no results" states.
Rules
Title says what is empty in the user's words ("No projects yet", "Nothing waiting for you"), never "No data", "Empty" or "404".
Description is one sentence, 14px, capped at 352px, that says why or what will fill it; skip it when the title is enough.
One primary action at most, and only if the user can fill the region from here; a second action is outline or link-color, 12px before it.
The header holds one thing: a modern gray FeaturedIcon (48px, 56 at lg), an illustration, or a file icon. Never a colored disc, never an emoji.
Filter-empty ("No results for X") is size sm with the with-search variant, keeps the filters visible and offers "Clear search"; it never uses a card or dashed frame.
Use dashed only for drop zones and "create the first one" slots; card only when the empty state is the whole canvas region; illustration for onboarding moments, not for every empty table.
Loading is not empty: show a Skeleton until the data arrives, then the EmptyState if it is truly empty.
Do not center the empty state vertically in a tall page; keep it at the top with its padding so it reads like content.
Accessibility
The title is a real heading at the level of the region it replaces (h2 for a page, h3 for a card); do not skip levels.
The featured icon and illustration are aria-hidden; nothing in the empty state depends on them.
When the empty state appears after a filter or search, announce it with aria-live="polite" on the region so the change is heard.
Drop zones also work by keyboard: the "Choose file" Button opens the file picker; the dashed area is not the only way.
A search field inside the empty state keeps the current query as its value and has an aria-label.
Feedback
FeaturedCard .cn-featured-cardedit
The small promo or usage card at the bottom of a sidebar: a bg-subtle card with a label-sm title, body-sm muted description, an optional 8px progress bar or 16:9 image, a ghost "Dismiss" and a link-color action, and a 20px × in the corner.
Use: Use for one gentle nudge that lives in navigation: plan usage, a trial countdown, a new app or feature, an invite prompt. One per sidebar, always dismissible. For page-level messages use Banner or Alert; for a row inside content use InlineCta.
Usage with progressThe bottom-of-sidebar usage card: number in the text, bar below, two quiet actions.
Used space
Your team has used 80% of the available space. Need more?
HTML
<div class="cn-featured-card" data-variant="progress" data-tone="neutral" role="complementary" aria-label="Sidebar notice" style="width:100%;max-width:264px"><div class="cn-featured-card__title">Used space</div><p class="cn-featured-card__description">Your team has used 80% of the available space. Need more?</p><div class="cn-featured-card__progress" role="progressbar" aria-label="Storage used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80"><div class="cn-featured-card__progress-fill" style="width:80%"></div></div><div class="cn-featured-card__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">Upgrade plan</span></button></div><button type="button" class="cn-featured-card__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Announcement with image, brand toneOne brand-toned card per sidebar; the image is 16:9 with radius md.
Lumen for iOS is here
Approve requests and reply to comments from your phone.
Trial countdown, brand toneCountdowns use the progress variant with the days in the description.
Trial ends in 4 days
10 of 14 days used. Keep your dashboards by choosing a plan.
HTML
<div class="cn-featured-card" data-variant="progress" data-tone="brand" role="complementary" aria-label="Sidebar notice" style="width:100%;max-width:264px"><div class="cn-featured-card__title">Trial ends in 4 days</div><p class="cn-featured-card__description">10 of 14 days used. Keep your dashboards by choosing a plan.</p><div class="cn-featured-card__progress" role="progressbar" aria-label="Trial days used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="71"><div class="cn-featured-card__progress-fill" style="width:71%"></div></div><div class="cn-featured-card__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">Choose a plan</span></button></div><button type="button" class="cn-featured-card__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Matrix
variant ↓ / tone →
neutral
brand
progress
Used space
Your team has used 80% of the available space. Need more?
Used space
Your team has used 80% of the available space. Need more?
image
Used space
Your team has used 80% of the available space. Need more?
Used space
Your team has used 80% of the available space. Need more?
text
Used space
Your team has used 80% of the available space. Need more?
Used space
Your team has used 80% of the available space. Need more?
Anatomy, props, states, rules
Anatomy
.cn-featured-card<div> — The card: bg-subtle, radius card, 16px padding, flex column with 12px gaps, relative for the × button. 264px wide in a sidebar.
.cn-featured-card__title<div> — label-sm title, one line, with room on the right for the ×.
.cn-featured-card__description<p> — body-sm muted, one to three lines. Includes the number when there is one ("80% of 10 GB used").
.cn-featured-card__progress<div> · optional — 8px track (bg-muted, radius full) for usage and countdowns. role="progressbar".
.cn-featured-card__progress-fill<div> · optional — The action-colored fill; width is the inline percentage.
.cn-featured-card__media<div> · optional — 16:9 image slot with radius md above the actions (announcements).
.cn-featured-card__actions<div> · optional — Row of two sm Buttons: ghost "Dismiss" first, link-color action last.
.cn-featured-card__dismiss<button> · optional — 24px hit area with a 20px × in the top-right corner. aria-label "Dismiss".
Props
prop
values (default outlined)
meaning
variant
progressimagetext
progress = title, description and an 8px bar (storage used, seats, trial days left). image = title, description and a 16:9 picture (a new app, a feature launch). text = title and description only (invite your team, complete your profile).
tone
neutralbrand
neutral = bg-subtle card, the everyday usage card. brand = bg-action-subtle with an action-colored title for the one promotional card (upgrade, trial). Never two brand cards in a sidebar.
States
state
selector
meaning
dismissHover
.cn-featured-card__dismiss:hover
Pointer over the × (extraCss): muted fill, ink icon. (native :hover on .cn-featured-card__dismiss)
dismissFocus
.cn-featured-card__dismiss:focus-visible
Keyboard focus on the × shows the focus ring (extraCss). (native :focus-visible on .cn-featured-card__dismiss)
Rules
One featured card per sidebar, always at the bottom above the account card, always dismissible.
Title in label-sm, one line, sentence case; description at most three lines and includes the number ("80% of 10 GB used").
Exactly two actions: a ghost sm "Dismiss" first and one link-color sm action last. Never a primary button in the sidebar.
The × dismisses for this session; the "Dismiss" button dismisses for good. Do not show the card again within 30 days.
Progress fills with the action color and warns in the text, not by turning red; the sidebar is not the place for alarms.
brand tone only for the one promotional card (upgrade, trial); usage and housekeeping cards are neutral.
Images are 16:9, radius md, illustrative; never a screenshot with text small enough to need reading.
Do not stack a FeaturedCard with a Banner about the same thing; pick the surface closest to where the action happens.
Accessibility
The card is a complementary region (role="complementary" or an <aside>) with an aria-label so it can be skipped.
The progress bar carries role="progressbar" with aria-valuenow/min/max and an aria-label; the percentage also appears in the description text.
The × has aria-label "Dismiss" and comes last in the tab order inside the card; Escape does nothing here (the card is not modal).
Images have alt text or aria-label describing what is promoted; decorative art uses alt="".
Both actions are real <button>s or links; the link-color action opens the page it names.
Feedback
InlineCta .cn-inline-ctaedit
A bordered row inside content that asks for one thing: a 40px featured icon or avatar, a label-md title, a body-sm description, a ghost secondary and a link or primary action, an optional progress bar and a × to dismiss. Tones tint the surface for brand, warning and success.
Use: Use between content blocks when the next step lives right there: upgrade to unlock a feature, verify an email before sending, free up storage, act on an invitation. For sidebar nudges use FeaturedCard; for page-wide messages use Banner; for form errors use Alert.
Upgrade to Pro (brand)A featured icon, two lines and a primary action on the action-subtle surface.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
With avatar (success)A person's action shows their avatar instead of an icon.
DC
Daniel Costa accepted your invite
He now has access to the Q3 launch plan workspace.
HTML
<div class="cn-inline-cta" data-variant="with-avatar" data-tone="success" role="region" aria-label="Notice" style="width:100%;max-width:640px"><span class="cn-inline-cta__media"><span class="cn-avatar" data-size="lg" data-shape="circle" data-tone="neutral" data-status="none" role="img" aria-label="Daniel Costa"><span class="cn-avatar__fallback" aria-hidden="true">DC</span></span></span><div class="cn-inline-cta__content"><div class="cn-inline-cta__title">Daniel Costa accepted your invite</div><p class="cn-inline-cta__description">He now has access to the Q3 launch plan workspace.</p><div class="cn-inline-cta__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Dismiss</span></button><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">View profile</span></button></div></div><button type="button" class="cn-inline-cta__dismiss" aria-label="Dismiss"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></div>
Matrix
variant ↓ / tone →
neutral
brand
warning
success
default
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
with-progress
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
with-avatar
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
banner
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Upgrade to Pro
Unlimited dashboards, 30-day history and priority support for your whole team.
Anatomy, props, states, rules
Anatomy
.cn-inline-cta<div> — The row: bg-surface, hairline, radius card, 16px padding, shadow-xs, flex with 16px gaps, relative for the ×.
.cn-inline-cta__media<span> · optional — Wrapper for a 40px FeaturedIcon (system prompts) or a lg Avatar (a person's action).
.cn-inline-cta__content<div> — Title, description and, in the stacked variants, the actions and progress.
.cn-inline-cta__title<div> — label-md title, one line: what is offered or needed ("Verify your email address").
.cn-inline-cta__description<p> — body-sm muted, one or two lines: why, and the concrete number when there is one.
.cn-inline-cta__progress<div> · optional — 8px track (bg-muted, radius full) under the description for used-of-limit prompts. role="progressbar".
.cn-inline-cta__progress-fill<div> · optional — The action-colored fill; width is the inline percentage.
.cn-inline-cta__actions<div> — Row of sm Buttons: ghost secondary first, then one link-color or primary action. Inside the content, or beside it in the banner variant.
.cn-inline-cta__dismiss<button> · optional — 28px button with a 20px × in the top-right corner (or at the end of the banner row). aria-label "Dismiss".
Props
prop
values (default outlined)
meaning
variant
defaultwith-progresswith-avatarbanner
default = featured icon, text, actions under the text. with-progress = adds an 8px progress bar between the description and the actions (storage, seats, quota). with-avatar = a person's avatar instead of the icon, for invitations and requests from someone. banner = one line: text on the left, actions on the right, × at the end; for wide content areas.
tone
neutralbrandwarningsuccess
neutral = white surface with a hairline, for housekeeping (storage, invites). brand = action-subtle surface for upgrades and new features. warning = warning-subtle surface for something that blocks a next step (verify, expiring). success = success-subtle surface for a completed action that has a follow-up (accepted, connected).
States
state
selector
meaning
dismissHover
.cn-inline-cta__dismiss:hover
Pointer over the × (extraCss): subtle fill, ink icon. (native :hover on .cn-inline-cta__dismiss)
dismissFocus
.cn-inline-cta__dismiss:focus-visible
Keyboard focus on the × shows the focus ring (extraCss). (native :focus-visible on .cn-inline-cta__dismiss)
Rules
One ask per row: the title names it, the description gives the reason and the number, the last action does it.
At most two actions: a ghost sm secondary ("Dismiss", "Later") first and one link-color or primary sm action last. Primary only for the one conversion of the page.
Featured icon for system prompts, avatar for a person's action; never both, never an image.
Tone follows meaning: brand for upgrades and launches, warning for something that blocks the next step, success for a completed action with a follow-up, neutral for housekeeping.
Progress appears only with a real limit and the used-of-total in the description; the fill stays action-colored, the text carries the warning.
Use banner only when the row is wider than 640px; below that the actions wrap under the text (default).
Every InlineCta is dismissible unless the ask blocks the page (then it is a Banner). Remember the dismissal per user.
Place it next to what it talks about (above the upload list, under the email field), never at the top of every page.
Accessibility
The row is a labelled region (role="region" + aria-label) or a plain <section>; use role="status" only when it appears in response to an action.
Featured icons are aria-hidden; avatars with initials carry role="img" + aria-label with the person's name.
The progress bar has role="progressbar" with aria-valuenow/min/max and an aria-label; the same number is in the description.
The × has aria-label "Dismiss" and is the last Tab stop in the row; actions are real <button>s or links.
Tone is never the only signal: the title says "Verify", "Upgrade" or "Accepted" in words.
Feedback
Notification .cn-notificationedit
An in-app notification card: a 400px raised white panel with an optional avatar or featured icon on the left, a semibold title, one or two lines of supporting text, a timestamp and up to two text actions. Unlike Toast it is not timed: it sits in a notification panel or feed until dismissed.
Use: Use in a notifications drawer, a bell-menu panel or an inbox list to tell a person something happened that they may act on later: a mention, a comment, an upload finishing, a product announcement. Not for transient confirmations (Toast), not for page-level warnings (Alert or Banner) and not for anything that needs a decision now (Dialog).
Danger tone, sync failedDanger notifications report a background failure and offer the fix as the link action; anything needing an immediate decision is a Dialog instead.
Pipedrive sync failed
14 deals were not updated. The token expired on Sep 9.
.cn-notification<div> — The card. 400px wide (max 100%), raised surface, hairline, radius card, shadow-lg, padding 16. Flex row: media, content, then the absolutely-positioned dismiss button. role="status".
.cn-notification__media<span> · optional — Optional 40px slot on the left: an avatar circle with initials (with-avatar) or a featured icon square/circle holding a 20px icon (with-featured-icon). Tone colors the featured icon.
.cn-notification__content<div> — Title, description, time, optional progress, image and actions. Flexes to fill; keeps 32px of right padding clear for the dismiss button.
.cn-notification__title<div> — label-md (14 semibold), one line: who did what ("Maya Chen commented on your post") or what happened ("Export ready").
.cn-notification__description<p> · optional — body-md muted, one or two lines: the comment excerpt, the file name, the change summary.
.cn-notification__time<time> · optional — body-sm subtle timestamp under the description ("2 minutes ago"). Use a <time datetime>.
.cn-notification__progress<div> · optional — Optional upload/processing row: an 8px track with the action-colored fill plus a percentage label (label-sm muted). Set the fill width inline and mirror it in aria-valuenow.
.cn-notification__progress-track<div> · optional — The 8px rounded track (bg-muted) inside the progress row. role="progressbar".
.cn-notification__progress-fill<div> · optional — The filled portion of the track. Width is the percentage (inline style).
.cn-notification__image<img> · optional — Optional full-width 16:9 image under the text (product announcements). Radius md, object-fit cover.
.cn-notification__actions<div> · optional — Row of at most two Buttons: a ghost sm ("Dismiss", "Later") then a link sm ("View changes", "Reply").
.cn-notification__dismiss<button> · optional — 36px ghost icon button with a 20px X, absolutely positioned in the top-right corner (8px inset). aria-label="Dismiss".
default = text only (title, description, time); with-avatar = a 40px avatar of the person who acted, for social events (comments, mentions, invites); with-featured-icon = a 40px featured icon for system events (payment, export, sync); with-image = a full-width 16:9 image below the text, for announcements; with-progress = a progress row for uploads and long jobs; with-actions = the text plus a Dismiss + primary-link action row (any variant may also carry actions).
tone
neutralsuccesswarningdanger
Colors the featured icon only; the card stays white. neutral = informational (grey icon in a hairline square); success = something completed (green tinted circle); warning = needs attention soon (amber); danger = something failed (red). Avatars and images ignore the tone.
States
state
selector
meaning
dismissHover
.cn-notification__dismiss:hover
Pointer over the dismiss button: subtle fill, default ink. Styled in extraCss because it lives on a child. (native :hover on .cn-notification__dismiss)
dismissFocus
.cn-notification__dismiss:focus-visible
Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-notification__dismiss)
unread
[data-unread]
Not yet seen: the title stays semibold and a 8px action-colored dot sits after the title. Clear the attribute when the panel is opened. (data-unread on the root)
Rules
Width is 400px (max 100%); the card never stretches to fill a wide panel. Panels and drawers are sized to the card, not the other way around.
Title is one line, sentence case, no period: "{Person} {verb} {object}" for social events, "{Object} {state}" for system events.
Description is at most two lines: a quoted excerpt, a file name with size, or the concrete change. Truncate long comments with an ellipsis and let the action open the full thing.
Always show a timestamp (relative under 24h, absolute after) in a <time datetime>.
At most two actions: a ghost sm secondary first ("Dismiss", "Later", "Decline") and one link sm primary last ("View changes", "Reply", "Accept").
The card stays white in every tone; only the featured icon is tinted. Never tint the whole card or add a colored left bar.
Avatar for people, featured icon for the system, image only for announcements. Never two media slots.
Progress rows show a percentage and a plain-language remainder ("about 20 seconds left"); when the job ends the card becomes a success notification, it does not disappear.
Unread state is a dot after the title plus semibold; clear it when the panel opens, never require a click per card.
Accessibility
role="status" on the card so new notifications are announced politely; the panel that holds them is a region labelled "Notifications".
The dismiss button has aria-label="Dismiss" and is the last Tab stop inside the card; Escape inside the panel dismisses the focused card.
Avatars with initials carry role="img" and aria-label="Full name"; featured icons and images used as decoration are aria-hidden.
Progress uses role="progressbar" with aria-valuenow/min/max on the track and the percentage as visible text.
The unread dot is decorative; expose "unread" in the accessible name or a visually hidden word so screen readers get it too.
Feedback
ProgressCircle .cn-progress-circleedit
A ring that shows a share of a whole: a hairline track, a round-capped indicator in the action (or status) color, and the value centered inside with an optional caption. The half variant is a gauge that opens downward.
Use: Use for one headline percentage that deserves space: storage used, goal completion, a KPI on a dashboard card, an upload in a dialog. For inline or row-level progress use Progress (the bar); for a wait without a value use Spinner; for several metrics at once use ActivityGauge.
.cn-progress-circle<div> — Square box (inline-flex column, centered) that positions the svg behind the label. role="progressbar" with aria-valuenow/min/max and an aria-label. Carries data-size, data-variant, data-tone.
.cn-progress-circle__track<circle> — The full ring in the hairline color (stroke border-default). r = 42 in a 0 0 100 100 viewBox, fill none.
.cn-progress-circle__indicator<circle> — The value ring: same geometry, stroke in the tone color, round caps, dasharray 263.89. Its stroke-dashoffset is an inline style computed from the value.
.cn-progress-circle__label<div> — The centered value ("72%"), tabular numerals. Type scales with size: label-xs / label-sm / heading-sm / heading-lg.
.cn-progress-circle__caption<div> · optional — body-xs muted line under the value ("Active users"). Hidden on xs.
Props
prop
values (default outlined)
meaning
size
xssmmdlg
Outer box 64 / 96 / 160 / 200px. xs = inside table rows and list items (value only); sm = stat tiles; md = a dashboard card; lg = a page hero or an empty-state style focus.
variant
circlehalf
circle = full ring, value in the middle; half = the top half of the ring as a gauge, value sitting on the chord. Use half when the number reads as a level (capacity, health) rather than completion.
tone
actionsuccesswarningdanger
Indicator color. action = default; success = completed or healthy; warning = near a limit (≥ 80%); danger = over the limit or failed.
Rules
Geometry is fixed: viewBox 0 0 100 100, r = 42, circumference 263.89. Set the value only through the inline style on the indicator: stroke-dashoffset = 263.89 × (1 − value / 100); for the half variant 131.95 × (1 − value / 100).
The label is HTML, never SVG text, so it uses the type scale and stays selectable; keep it to a number or a short word ("Done").
Tone follows the value, not the brand: switch to warning at ≥ 80% of a limit and to danger when the limit is exceeded or the task failed; success only at 100% or "healthy".
One ProgressCircle per card. Several percentages side by side are a table or an ActivityGauge.
xs has no caption: put the meaning in the row label next to it.
Never animate on first paint; transition only between value updates (260ms).
Use half for levels (capacity, health, score) and circle for completion (progress, share of a goal).
Stroke width scales with the size (10 / 8 / 6 / 5 viewBox units) so the ring stays around 6–10px at every size; do not override it.
Accessibility
role="progressbar" with aria-valuenow, aria-valuemin="0", aria-valuemax="100" and an aria-label naming what is measured ("Active users: 72%").
Use aria-valuetext when the visible label is not a percentage ("118 of 212 verified").
The svg is aria-hidden; the label text is the visible equivalent of the ring, so color is never the only signal.
Respect prefers-reduced-motion: the dashoffset transition is the only motion and can be disabled globally.
Overlays
Menu .cn-menuedit
The dropdown panel of the reference: a 248px raised white surface (radius 8, hairline ring, shadow-lg) of 36px items with 16px icons, right-aligned shortcuts, gray-200 separators, section labels, an optional account-card header and a destructive last item. Styles the panel only; positioning is a separate anchor layer.
Use: Use for a short list of actions on one object (row actions, "More" in a card header, the account menu) and for grouped option toggles (sort, view, switch account). Not for navigation (SidebarNav), not for picking a form value (Select / Combobox), not for anything that needs a search field (CommandPalette).
Actions on one object248px panel; verbs in sentence case, "…" when the item opens a dialog. Delete is last, after a separator, in red.
Account menu with headerThe header is the account card (Avatar md + name + email); the "Switch account" section uses 20px avatars with the check on the right.
Submenu, highlighted and disabled itemsdata-highlighted is what keyboard navigation sets; a submenu item ends in a chevron; disabled items stay visible at 50%.
Anchored to a triggerWrap trigger + panel in .cn-menu-anchor; data-floating positions the panel 4px under the trigger. Add data-align="end" to right-align, data-side="top" to flip.
.cn-menu<div> — The panel: role="menu", 248px wide, bg-surface-raised, gray-200 ring, radius 8, shadow-lg, 4px vertical and 6px horizontal padding. data-state="open|closed".
.cn-menu__header<div> · optional — Optional account card at the top: an Avatar md with the avatar label group (name + email), 12px padding, hairline below; bleeds to the panel edges.
.cn-menu__group<div> · optional — role="group" wrapping related items, optionally labelled.
.cn-menu__label<div> · optional — Section heading in 12px semibold fg-subtle, padding 6 × 10 (4 below): "Sort by", "Switch account". Referenced by the group's aria-labelledby.
.cn-menu__item<button> — One action or option: role="menuitem" | "menuitemcheckbox" | "menuitemradio". 36px tall, padding 8 × 10, radius 6, 14px medium gray-700. Holds icon / avatar / check + text + shortcut or chevron.
.cn-menu__icon<svg> · optional — 16px leading icon, fg-subtle. All items in a group have one, or none do.
.cn-menu__avatar<img> · optional — 20px round avatar in the leading slot (account switchers, assignees).
.cn-menu__check<svg> · optional — 16px brand check mark of checkbox / radio items, in the leading slot (or trailing after an avatar); invisible until aria-checked="true" so text stays aligned.
.cn-menu__text<span> · optional — The item label; grows and truncates.
.cn-menu__shortcut<kbd> · optional — Keyboard shortcut in 12px medium fg-subtle, pushed to the end. Only for shortcuts that really work.
.cn-menu__chevron<svg> · optional — 16px chevron-right at the end of an item that opens a submenu.
.cn-menu__separator<div> · optional — A 1px gray-200 rule bleeding to the panel edges, 4px above and below. role="separator".
Props
prop
values (default outlined)
meaning
size
smmd
md = 36px items, 14px medium; the reference. sm = 32px items (padding 6 × 8) for dense table row menus and toolbars.
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden. The panel stays in the DOM for focus management but is display: none. (data-state="closed")
The item under the pointer or reached by arrow keys (on the ITEM): gray-50 fill, text one step darker. (data-highlighted on the item (keyboard) or native :hover)
checked
.cn-menu__item[aria-checked="true"]
A checkbox / radio item that is on: the brand check mark becomes visible. (aria-checked="true" on a role="menuitemcheckbox|menuitemradio" item)
danger
.cn-menu__item[data-danger]
Destructive action: fg-danger text and icon, danger-subtle fill when highlighted. Always last, after a separator. (data-danger on the item)
disabled
.cn-menu__item[aria-disabled="true"]
Not available now: 50% opacity, cursor not-allowed, no hover. Stays visible and focusable so the user learns it exists. (aria-disabled="true" on the item)
Rules
Max 7 items per group and about 12 in total. Beyond that, the object has too many actions; move rare ones into a dialog or a settings page.
Items are verbs in sentence case ("Duplicate", "Move to…"); "…" when the item opens a dialog. Options (sort, show) are nouns with checks.
Items are 36px (sm 32) with 14px medium text; icons 16px fg-subtle, all-or-none within a group; shortcuts 12px on the right only when the key really works.
Destructive actions are last, after a separator, marked data-danger, and always confirm.
The panel is 248px wide (or wider to fit the trigger when the trigger is a full-width control); never narrower.
Position with .cn-menu-anchor + data-floating (4px below the trigger, aligned to its start; data-align="end" to align to its end, data-side="top" to flip). One open menu at a time.
The account header is the only place for an avatar label group inside a menu; items use 20px avatars.
No submenus deeper than one level; the chevron item opens a second panel to the side.
Disabled items stay visible with aria-disabled and explain themselves in a Tooltip; hiding them makes the menu feel unstable.
The panel styles only; put backdrop, focus trap and scroll lock in the app layer, never in the component.
Accessibility
Root has role="menu" and aria-labelledby pointing at the trigger (or aria-label). The trigger has aria-haspopup="menu", aria-expanded and aria-controls.
Items are role="menuitem", "menuitemcheckbox" or "menuitemradio" with aria-checked; groups are role="group" with aria-labelledby; separators are role="separator".
Keyboard: Down/Up move highlight (wrapping), Home/End jump, Enter/Space activate, Right opens a submenu, Esc closes and returns focus to the trigger, typing jumps to a matching item.
Only one item is tabbable (roving tabindex); highlight is set with data-highlighted and reflected by aria-activedescendant or focus.
Disabled items use aria-disabled="true" and remain focusable so they can be announced; never the disabled attribute.
Avatars inside items are decorative (alt=""); the text names the person.
Overlays
CommandPalette .cn-command-paletteedit
The ⌘K menu of the reference: a 640px raised surface with a 56px search row on top, grouped results (40px options, or 64px stacked people rows with avatars) in the middle and a 52px key legend at the bottom. Styles the panel only; the fixed backdrop is a separate layer.
Use: Use as the one global launcher of the app: jump to any record, person or page and run any command by typing. Opened with ⌘K / Ctrl+K from anywhere and from the search field in the Topbar or Sidebar. Not a replacement for a page's search Input, not a Menu (no typing) and not a Select.
ActionsBefore typing: recent things, navigation with G-prefixed shortcuts, then commands. The first option is highlighted; a disabled command stays listed.
.cn-command-palette__search<div> — The 56px search row with a hairline below: 20px search icon, bare input, ⌘K key chip.
.cn-command-palette__icon<svg> — 20px search icon, fg-subtle, leading the input. Decorative.
.cn-command-palette__input<input> — The bare text field, 16px, no border. role="combobox" controlling the list, aria-activedescendant pointing at the highlighted option.
.cn-command-palette__list<div> — role="listbox": scrolling results (max 360px), 8px padding, groups of options.
.cn-command-palette__group<div> · optional — role="group" with aria-labelledby its label.
.cn-command-palette__group-label<div> · optional — Group heading in 12px medium fg-subtle, padding 8 × 8 (4 below): "Recent", "People", "Commands".
.cn-command-palette__item<div> — role="option": a 40px row (64px in stacked), radius 6, padding 8 × 8, 14px medium; gray-50 when highlighted. Holds icon or avatar + label + optional meta + optional shortcut.
.cn-command-palette__item-icon<svg> · optional — 16px leading icon, fg-subtle: what kind of thing this is (person, page, command).
.cn-command-palette__item-avatar<span> · optional — A 32px Avatar (40px in stacked) in the leading slot of people rows, with its status dot.
.cn-command-palette__item-text<span> · optional — Column of label + meta for stacked rows.
.cn-command-palette__item-label<span> — The name of the thing or the command, 14px medium fg-default. Fills the row and truncates.
.cn-command-palette__item-meta<span> · optional — 14px fg-muted context after (or under) the label: "@maya", "Settings".
.cn-command-palette__item-shortcut<kbd> · optional — Key chip (12px medium fg-subtle, radius 4, gray-200 ring, padding 1 × 4) showing the global shortcut of a command; one chip per key.
.cn-command-palette__empty<div> · optional — Centered muted message shown instead of the list when nothing matches; echoes the query.
.cn-command-palette__footer<footer> — 52px legend on a hairline: ↑↓ to navigate · ↵ to select · esc to close.
.cn-command-palette__key<kbd> — One key chip in the search row or footer: 12px medium fg-subtle on bg-surface with a gray-200 ring.
Props
prop
values (default outlined)
meaning
variant
defaultstacked
default = 40px rows with the meta after the label (commands, pages, quick jumps). stacked = 64px rows with a 40px avatar and the meta under the label (people and records with two lines).
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden. The panel is display: none; the backdrop layer is removed by the app. (data-state="closed")
highlighted
.cn-command-palette__item[data-highlighted]
The option the arrow keys are on (on the ITEM): gray-50 fill, darker icon. Exactly one, the first result by default. (data-highlighted on the option)
selected
.cn-command-palette__item[aria-selected="true"]
The option referenced by aria-activedescendant; same look as highlighted so pointer and keyboard agree. (aria-selected="true" on the option)
disabled
.cn-command-palette__item[aria-disabled="true"]
A command not available in this context: 50% opacity, no hover; still listed so it can be discovered. (aria-disabled="true" on the option)
Rules
One palette per app, opened with ⌘K / Ctrl+K from anywhere and from the search field in the Topbar or Sidebar. It is the only global launcher.
The panel is 640px wide with a 56px search row; options are 40px (stacked 64px) with 14px medium labels, 16px icons or 32 / 40px avatars, radius 6.
The placeholder is short ("Search…") and the search row carries the ⌘K chip so people learn the shortcut.
Group order is fixed: matching records first, then people, then navigation ("Go to"), then commands. Max 4 groups visible, 5–7 options each; the rest is reachable by typing.
The first option is always highlighted so ↵ does something immediately. Highlight follows the pointer and the arrow keys, never both at once.
Always render an empty state that echoes the query and suggests what to type; never an empty list.
The footer legend is always visible; shortcuts on options appear only when they work globally, one key chip per key.
Labels are the name of the thing (record title, page title, command verb); meta says what and where it is. No descriptions longer than a few words.
Render inside .cn-command-palette-backdrop: fixed, overlay z-index, 6px blur, panel top-aligned at 15vh; never vertically centered.
The panel is the component; open/close, focus trap, filtering and scroll lock live in the app layer.
Accessibility
Root is role="dialog" aria-modal="true" with an aria-label; opening moves focus into the input, closing returns it to where the user was.
The input is role="combobox" with aria-expanded, aria-controls (the listbox), aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option.
The list is role="listbox"; groups are role="group" aria-labelledby their label; options are role="option" with aria-selected on the highlighted one.
Keyboard: ↑/↓ move the highlight (wrapping across groups), ↵ runs the option, Esc closes, typing filters; focus stays in the input the whole time.
Announce result counts with an aria-live="polite" region ("4 results") and use role="status" on the empty state.
Avatars in people rows are decorative (aria-hidden); the label names the person and the meta carries the status in words when it matters.
Key chips are read naturally ("↑ ↓ to navigate"); do not rely on glyphs alone for commands with shortcuts.
Overlays
Tooltip .cn-tooltipedit
Small inverse-colored label that names or explains a control on hover and focus. One short sentence, a 6px arrow toward the trigger, nothing interactive inside.
Use: Use to name icon-only buttons and to add a hint to truncated values or abbreviations. Not for content the user needs to act on or copy (use Popover), not for long explanations (use helper text), and not as the only place a label lives on touch devices.
Top (default)
Archive this prospect
HTML
<div class="cn-tooltip" data-side="top" data-state="open" role="tooltip" id="tip-archive">Archive this prospect<span class="cn-tooltip__arrow"></span></div>
<div class="cn-tooltip" data-side="left" data-state="open" role="tooltip" id="tip-verified">Verified with 3 public sources<span class="cn-tooltip__arrow"></span></div>
Right, wrapping at 240px
Fit score combines trade volume, product match and reply history.
HTML
<div class="cn-tooltip" data-side="right" data-state="open" role="tooltip" id="tip-score">Fit score combines trade volume, product match and reply history.<span class="cn-tooltip__arrow"></span></div>
Recipes
Icon button with its tooltip (static)The trigger points to the tooltip with aria-describedby; the tooltip never contains the trigger’s label alone if the button has visible text.
.cn-tooltip<div> — The panel. role="tooltip", id referenced by the trigger’s aria-describedby. Positioned by the app; the component styles it in place.
.cn-tooltip__arrow<span> · optional — 6px square rotated 45°, same background as the panel, placed on the edge facing the trigger according to data-side.
Props
prop
values (default outlined)
meaning
side
toprightbottomleft
Which side of the trigger the tooltip sits on; only moves the arrow. top is the default; use bottom under a topbar, left/right next to a sidebar.
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden. The panel is removed from view (display none). (data-state="closed")
Rules
Text only, at most one sentence, no period at the end unless it is two sentences (then it is too long).
Never put links, buttons or inputs inside a tooltip. If it must be clicked, it is a Popover.
Show after a 300ms hover delay and immediately on keyboard focus; hide on pointer leave, blur or Escape.
Tooltips are not the only label on touch: every icon-only control also has aria-label, and mobile-only elements do not rely on tooltips at all.
Prefer side="top". Use bottom under a fixed topbar and left/right for controls at the screen edge. Let the positioning library flip when there is no room.
Max width 240px; wrapping is fine, scrolling is not.
The panel is inverse-colored in both themes (dark on light, light on dark); never tone it.
Do not show a tooltip that repeats the visible label of a text button.
Accessibility
role="tooltip" on the panel and aria-describedby on the trigger pointing to its id.
Opens on focus as well as hover; a control that can be hovered must be reachable by keyboard.
Escape closes it and returns nothing else; focus never moves into the tooltip.
Inverse colors keep AA contrast in both themes; body-xs is the minimum size, never smaller.
Overlays
Popover .cn-popoveredit
Small anchored panel with real content: a title, a short body, and at most two actions. Raised white surface, hairline, shadow-md and an optional arrow toward its trigger. The component styles the panel; the app positions it.
Use: Use for lightweight tasks next to their trigger: a quick filter, a date picker, a confirmation with one field, a preview card. Not for one-line hints (use Tooltip), not for flows that need the whole screen (use Dialog) and not for lists of commands (use Menu).
14 years active · 2 decision makers found · last outreach 3 days ago. Verified
HTML
<div class="cn-popover" data-size="lg" data-side="right" data-state="open" role="dialog" aria-labelledby="pop-company-title"><span class="cn-popover__arrow"></span><header class="cn-popover__header"><div><h3 class="cn-popover__title" id="pop-company-title">Nakamura Trading Co.</h3><p class="cn-popover__description">Osaka, Japan · Importer of frozen seafood</p></div><button type="button" class="cn-popover__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-popover__body">14 years active · 2 decision makers found · last outreach 3 days ago. <span class="cn-badge" data-tone="success" data-variant="soft" data-size="sm">Verified</span></div><footer class="cn-popover__footer"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Open profile</span></button></footer></div>
Left, confirmation
Remove from shortlist?
You can add it back from the search results.
HTML
<div class="cn-popover" data-size="md" data-side="left" data-state="open" role="dialog" aria-labelledby="pop-remove-title"><span class="cn-popover__arrow"></span><header class="cn-popover__header"><div><h3 class="cn-popover__title" id="pop-remove-title">Remove from shortlist?</h3><p class="cn-popover__description">You can add it back from the search results.</p></div></header><footer class="cn-popover__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Keep</span></button><button type="button" class="cn-button" data-variant="danger" data-size="sm"><span class="cn-button__label">Remove</span></button></footer></div>
Matrix
size ↓ / side →
top
right
bottom
left
sm
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
md
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
lg
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Filter by country
Prospects from these markets only.
Japan, South Korea, Germany selected.
Anatomy, props, states, rules
Anatomy
.cn-popover<div> — The panel. role="dialog" (or "region" when it is informational), data-state="open|closed", data-side for the arrow.
.cn-popover__header<header> · optional — Title + description on the left, close button on the right. Optional when the body speaks for itself.
.cn-popover__title<h3> · optional — heading-xs. Names the task ("Filter by country").
.cn-popover__description<p> · optional — body-sm muted, one line under the title.
.cn-popover__body<div> — body-sm content: a short form, a list, a preview.
.cn-popover__footer<footer> · optional — Right-aligned row: ghost cancel then one primary Button, size sm.
.cn-popover__close<button> · optional — 24px ghost icon button with an X at the top-right. aria-label="Close".
.cn-popover__arrow<span> · optional — 8px rotated square with the same surface and hairline, on the edge facing the trigger.
Props
prop
values (default outlined)
meaning
size
smmdlg
Panel width. sm = 240px for a single control or a short list; md = 320px for a small form (default); lg = 400px for a preview or a two-column form.
side
toprightbottomleft
Which side of the trigger the panel opens on; moves the arrow and its border. bottom is the default for buttons in a toolbar.
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden (display none). (data-state="closed")
closeHover
.cn-popover__close:hover
Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child. (native :hover on .cn-popover__close)
closeFocus
.cn-popover__close:focus-visible
Keyboard focus on the close button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-popover__close)
Rules
One task per popover. If it needs more than one field group or a scrolling body, it is a Dialog or a Drawer.
Width comes from size only; never set width inline. Height is content, max 60vh with an internal scroll on the body.
Footer: ghost cancel first, then one primary (or danger) Button, both size sm. No footer for informational popovers.
Opens on click (never on hover), closes on Escape, outside click, or the close button. The trigger gets aria-expanded.
Position with a floating library; flip side when there is no room and keep the arrow centered on the trigger.
Only one popover open at a time; opening another closes the first.
Use the arrow when the popover is detached from its trigger by more than 4px; omit it when it hugs a toolbar.
Enter animation: 4px slide + fade, 180ms; leave is fade only.
Accessibility
role="dialog" with aria-labelledby on the title when the popover contains controls; role="region" with aria-label when it is read-only.
Focus moves to the first control inside on open and returns to the trigger on close.
The trigger carries aria-expanded and aria-controls pointing to the panel id.
Escape closes; Tab cycles inside a dialog popover; the close button has aria-label="Close".
Overlays
Dialog .cn-dialogedit
Modal panel centered over a scrim for one decision or one short task. Raised white surface, radius-2xl, shadow-lg; header with a required title, a scrolling body, and a subtle footer where the single primary action sits last.
Use: Use when the user must decide or complete something before continuing: confirm a deletion, name a new search, pick between two plans. Not for details of a list item (use Drawer, which keeps the list visible), not for hints (Tooltip/Popover), and never as a container for a whole page of settings.
Default (md)
Name your shortlist
You can rename it later from the list header.
HTML
<div class="cn-dialog" data-size="md" data-variant="default" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-name-title"><header class="cn-dialog__header"><div><h2 class="cn-dialog__title" id="dlg-name-title">Name your shortlist</h2><p class="cn-dialog__description">You can rename it later from the list header.</p></div><button type="button" class="cn-dialog__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-dialog__body"><div class="cn-input" data-variant="default" data-size="md"><input class="cn-input__field" type="text" value="Japan · frozen seafood · Q4" aria-label="Shortlist name"></div></div><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Cancel</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Create shortlist</span></button></footer></div>
Destructive (sm)The only dialog that ends with a danger Button. Cancel stays first and ghost.
Delete this search?
“Germany · industrial pumps” and its 38 prospects will be removed. This cannot be undone.
Each company receives one email and one follow-up after 4 business days. Replies are drafted by the agent and wait for your approval.
HTML
<div class="cn-dialog" data-size="lg" data-variant="default" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-plan-title" style="max-height:360px"><header class="cn-dialog__header"><div><h2 class="cn-dialog__title" id="dlg-plan-title">Review the outreach plan</h2><p class="cn-dialog__description">12 companies · 3 waves · first wave tomorrow 09:00 JST</p></div><button type="button" class="cn-dialog__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-dialog__body"><p>Wave 1 (4 companies): Nakamura Trading Co., Hoffmann GmbH, Lagos Fresh Ltd., Marisco del Sur.</p><p style="margin-top:var(--cn-space-3)">Wave 2 (4 companies): Busan Cold Chain, Norsk Havfisk AS, Valencia Congelados, Pacific Reef Foods.</p><p style="margin-top:var(--cn-space-3)">Wave 3 (4 companies): Tokyo Maruha, Baltic Seafood OÜ, Dalian Ocean Trade, Cape Fisheries.</p><p style="margin-top:var(--cn-space-3)">Each company receives one email and one follow-up after 4 business days. Replies are drafted by the agent and wait for your approval.</p></div><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Back</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Start outreach</span></button></footer></div>
Matrix
variant ↓ / size →
sm
md
lg
xl
default
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
destructive
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
Name your shortlist
You can rename it later from the list header.
Anatomy, props, states, rules
Anatomy
.cn-dialog<div> — The panel. role="dialog" aria-modal="true" aria-labelledby → title. data-state="open|closed". Rendered inside .cn-dialog-backdrop in the app.
.cn-dialog__header<header> — Icon (optional) + title/description on the left, close button top-right. No bottom border.
.cn-dialog__icon<span> · optional — 32px circle before the title: subtle fill and muted icon by default; danger fill and icon in variant="destructive".
.cn-dialog__title<h2> — heading-md. Required. A question or a short verb phrase: "Delete this search?", "Name your shortlist".
.cn-dialog__description<p> · optional — body-sm muted, one or two sentences with the consequence.
.cn-dialog__close<button> · optional — 32px ghost icon button with an X in the top-right. aria-label="Close".
.cn-dialog__body<div> — body-md content: a field, a short form, a summary. Scrolls when the panel hits max-height.
.cn-dialog__footer<footer> — Subtle background, top hairline, right-aligned: ghost cancel first, then one primary (or danger) Button.
Props
prop
values (default outlined)
meaning
size
smmdlgxl
Max width of the panel. sm = 400px for a yes/no confirmation; md = 520px for one field or a short form (default); lg = 640px for a form with several fields; xl = 800px for a two-column layout or a preview. Pick two sizes per app and stick to them.
variant
defaultdestructive
default = neutral header icon (or none); destructive = the header icon turns danger (red circle) and the footer’s primary action is a danger Button. Use only for irreversible actions.
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden (display none). The backdrop is removed at the same time. (data-state="closed")
closeHover
.cn-dialog__close:hover
Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child. (native :hover on .cn-dialog__close)
closeFocus
.cn-dialog__close:focus-visible
Keyboard focus on the close button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-dialog__close)
Rules
A title is required and is the accessible name. Write it as the question being answered or the task being done.
Exactly one primary action, last in the footer. Cancel (or the escape hatch) comes first as a ghost Button, labelled with what it does ("Keep sending"), not "Cancel" when a better verb exists.
variant="destructive": header icon in danger colors and the primary becomes a danger Button. Never two filled buttons.
Render the panel inside .cn-dialog-backdrop (fixed, scrim, centered, z modal). The panel itself is never position: fixed.
Escape and the backdrop click close the dialog unless the body has unsaved input; then ask.
Pick at most two sizes per app (typically sm for confirmations and md for forms). xl is for previews only.
The body scrolls; header and footer stay put. Never let the whole panel exceed the viewport.
Do not stack dialogs. A dialog that needs another step becomes a stepper inside the same dialog or a full page.
Enter: scale 0.98 → 1 with fade, 260ms; the scrim fades in at the same time.
Accessibility
role="dialog" aria-modal="true" aria-labelledby pointing to the title (and aria-describedby to the description when present).
Focus is trapped inside while open; on open it goes to the first control (or the cancel button for destructive dialogs) and returns to the trigger on close.
Escape closes. The close button has aria-label="Close" and is the last element in the header.
Everything behind the dialog gets inert (or aria-hidden) while it is open; body scroll is locked.
Overlays
Drawer .cn-draweredit
Side panel that slides in over the page for the detail of one item while the list stays visible behind the scrim. Raised surface, a hairline on the inner edge, shadow-lg; sticky header and footer with a scrolling body.
Use: Use for the detail of a list or table row (a prospect, a conversation, an agent run), for editing one record, and for filters on mobile. Not for confirmations (use Dialog) and never as a replacement for a page: if the content has its own navigation, it is a page.
Right, md (item detail)height:420px is inline only so the gallery shows the bounded panel; in the app it fills the backdrop.HTML
<aside class="cn-drawer" data-side="right" data-size="md" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-nakamura" style="height:420px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-nakamura">Nakamura Trading Co.</h2><p class="cn-drawer__description">Osaka, Japan · Importer of frozen seafood · <span class="cn-badge" data-tone="success" data-variant="soft" data-size="sm">Verified</span></p></div><button type="button" class="cn-drawer__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-drawer__body"><p><strong>Why it fits</strong><br>Imports 1,200 t/year of frozen shrimp from Ecuador and Vietnam; two decision makers found; replied to a similar offer in 2025.</p><p style="margin-top:var(--cn-space-4)"><strong>Last activity</strong><br>Email opened 3 days ago · No reply yet · Follow-up scheduled for Tuesday 09:00 JST.</p><p style="margin-top:var(--cn-space-4)"><strong>Contacts</strong><br>Kenji Nakamura, Purchasing Director · Aiko Sato, Import Coordinator.</p></div><footer class="cn-drawer__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Archive</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Send follow-up now</span></button></footer></aside>
Bottom sheet (mobile)Bottom drawers are full width, 80vh tall, with rounded top corners. The wrapper is inline here only to bound the example.
HTML
<div style="width:100%;max-width:420px"><aside class="cn-drawer" data-side="bottom" data-size="md" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-sheet" style="height:300px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-sheet">Approve this reply?</h2><p class="cn-drawer__description">Drafted by the agent 2 minutes ago</p></div><button type="button" class="cn-drawer__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-drawer__body">“Thank you for your interest, Nakamura-san. We can supply 40 t of frozen shrimp per month from October, CIF Osaka. Would a call on Thursday suit you?”</div><footer class="cn-drawer__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Edit</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Approve and send</span></button></footer></aside></div>
Large, read-only (no footer)HTML
<aside class="cn-drawer" data-side="right" data-size="lg" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-run" style="height:360px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-run">Agent run · 11 Sep, 09:14</h2><p class="cn-drawer__description">Prospecting · Japan · frozen seafood · 4 min 12 s</p></div><button type="button" class="cn-drawer__close" aria-label="Close"><svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><div class="cn-drawer__body"><p>Read 3 replies from Nakamura, Hoffmann and Lagos Fresh. Drafted 3 answers. Flagged Hoffmann as “asks for a price list” — waiting for your file.</p><p style="margin-top:var(--cn-space-4)">Searched 212 companies, verified 38, discarded 174 (no trade data or wrong product).</p></div></aside>
Matrix
side ↓ / size →
sm
md
lg
right
left
bottom
Anatomy, props, states, rules
Anatomy
.cn-drawer<aside> — The panel. role="dialog" aria-modal="true" aria-labelledby → title. data-state="open|closed", data-side, data-size. Rendered inside .cn-drawer-backdrop in the app; in the gallery it is shown bounded with an inline height.
.cn-drawer__header<header> — Title/description on the left, close button on the right. Bottom hairline. Does not scroll.
.cn-drawer__title<h2> — heading-md. Names the item ("Nakamura Trading Co.") or the task ("Edit agent").
.cn-drawer__description<p> · optional — body-sm muted, one line of context under the title.
.cn-drawer__close<button> — 32px ghost icon button with an X in the header. aria-label="Close".
.cn-drawer__body<div> — The scrolling area. Padding space-6; content is sections, description lists, a timeline, a form.
.cn-drawer__footer<footer> · optional — Subtle background, top hairline, right-aligned actions: ghost first, one primary last. Only when the drawer edits something.
Props
prop
values (default outlined)
meaning
side
rightleftbottom
Edge the drawer slides in from. right = detail of a list item (default, the Vera pattern); left = navigation or filters; bottom = a sheet on mobile: full width, 80vh tall, rounded top corners.
size
smmdlg
Width for right/left drawers. sm = 360px for a short detail or filters; md = 480px for the detail of a record (default); lg = 640px for a conversation or an editor. Ignored for side="bottom".
States
state
selector
meaning
open
[data-state="open"]
Expanded / visible. (data-state="open")
closed
[data-state="closed"]
Hidden (display none); the backdrop is removed at the same time. (data-state="closed")
closeHover
.cn-drawer__close:hover
Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child. (native :hover on .cn-drawer__close)
closeFocus
.cn-drawer__close:focus-visible
Keyboard focus on the close button shows the focus ring. Styled in extraCss. (native :focus-visible on .cn-drawer__close)
Rules
The detail of a list item opens in a drawer, never in a new page. The list stays visible behind the scrim so the user keeps their place.
One level deep only: a drawer never opens another drawer. A link inside a drawer that needs more space closes it and navigates.
Render the panel inside .cn-drawer-backdrop with the same data-side; the panel itself is never position: fixed.
Header is sticky and carries the item name; the body scrolls; the footer appears only when the drawer edits something (ghost first, one primary last).
Width comes from size; never set it inline. Below 640px every drawer becomes full width (right/left) or a bottom sheet.
Close on Escape, on the close button and on scrim click, unless the body has unsaved edits; then ask.
Deep-link the open drawer (?prospect=123) so refresh and share keep it open.
Enter: 16px slide from its side with fade, 260ms; leave is the reverse at 180ms.
Accessibility
role="dialog" aria-modal="true" with aria-labelledby on the title.
Focus moves to the close button (read-only drawers) or the first field (edit drawers) on open, and returns to the row that opened it on close.
Focus is trapped inside while open; the page behind is inert.
The close button has aria-label="Close"; Escape closes.
Layout
Card .cn-cardedit
A bounded surface for one thing: an entity, a form section, a stat. A neutral surface, a fine outline and compact corners, with a subtle fill on selected cards.
Use: Group related content that stands alone (a customer, a settings section, a KPI). Do not use cards as a layout grid for everything; lists and tables are better for collections of similar rows. Use variant="interactive" only when the whole card is one link/action. Put a CardHeader first when the card needs a title with actions.
Default with header and footerComponent previewFlat surface, fine outline, 24px padding; the footer sits on a hairline.
Lumen website redesign
Marketing site · Due Sep 19
12 tasks, 4 in review. Sofia Almeida owns the design track, Daniel Costa the build.
HTML
<section class="cn-card" data-variant="default" data-padding="md" style="max-width:400px"><header class="cn-card__header"><div><h3 class="cn-card__title">Lumen website redesign</h3><p class="cn-card__description">Marketing site · Due Sep 19</p></div><div class="cn-card__actions"><button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="More actions"><svg class="cn-icon-button__icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="12.5" cy="8" r="1.25"/></svg></button></div></header><div class="cn-card__body"><p class="cn-text-body-md">12 tasks, 4 in review. Sofia Almeida owns the design track, Daniel Costa the build.</p></div><footer class="cn-card__footer">Updated 2h ago by Maya Chen</footer></section>
With a CardHeader and a table-like bodyComponent previewpadding="none" so the CardHeader hairline spans the card; the body sets its own 24px padding.
Team members
Manage your team and their permissions.
6 members · 1 invitation pending.
HTML
<section class="cn-card" data-variant="default" data-padding="none" style="max-width:480px"><header class="cn-card-header" data-variant="default" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Team members</h2><p class="cn-card-header__description">Manage your team and their permissions.</p></div><div class="cn-card-header__actions"><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Add user</span></button></div></header><div class="cn-card__body" style="padding:var(--cn-space-6)"><p class="cn-text-body-md">6 members · 1 invitation pending.</p></div><footer class="cn-card__footer"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Previous</span></button><span style="margin-inline:auto">Page 1 of 3</span><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Next</span></button></footer></section>
Interactive and selectedComponent previewHover strengthens the outline; selection adds an action-tinted surface.
<div style="display:flex;gap:var(--cn-space-6);flex-wrap:wrap;align-items:flex-start"><section class="cn-card" data-variant="outlined" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Outlined: ring only, no fill or shadow.</div></section><section class="cn-card" data-variant="elevated" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Elevated: shadow-md on a same-color canvas.</div></section><section class="cn-card" data-variant="ghost" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Ghost: a quiet gray-50 group inside another card.</div></section></div>
Anatomy, props, states, rules
Anatomy
.cn-card<section> — The surface: surface fill, fine outline, token-defined corners; padding via the padding prop.
.cn-card__header<header> · optional — Title row with optional actions on the right, inside the padding (for a full-width hairline header use CardHeader with padding none).
.cn-card__title<h3> · optional — heading-sm (18/28 semibold). One per card.
.cn-card__description<p> · optional — 14px fg-muted, under the title.
.cn-card__actions<div> · optional — Right-aligned row of ghost / outline buttons in the header, 12px apart.
.cn-card__body<div> — Main content.
.cn-card__footer<footer> · optional — Meta or actions on a gray-200 hairline, 16 × 24px padding (12 / 24 / 16 in a padding="none" card, pagination footer; add data-padding="none" on the footer itself to zero it); on a subtle surface.
Props
prop
values (default outlined)
meaning
variant
defaultoutlinedelevatedinteractiveghost
default = a flat surface with a fine outline; outlined = ring only, transparent background (on white canvases); elevated = shadow-md (only on a same-color canvas); interactive = whole card is one link or action: the edge strengthens and the surface changes on hover; ghost = no ring, gray-50 fill (a quiet group inside another card).
padding
nonesmmdlg
Inner padding of header/body/footer: none for tables, lists and media that bleed to the edge; sm 16px (compact cards in a grid); md 24px (default); lg 32px (hero cards and settings sections).
States
state
selector
meaning
hover
:hover
Only interactive cards react: a stronger edge and subtle fill. (native)
focus
:focus-visible
Keyboard focus. Shows the 3px ring; never on mouse click. (native :focus-visible)
selected
[aria-selected="true"], &[data-selected]
Selected item. (aria-selected="true" or data-selected)
Rules
Resting cards have a fine outline. Only floating layers use elevation; interactive cards respond through their edge and surface.
One title per card. Use a CardHeader (padding none) when the title needs a hairline, a badge, media or tabs; the plain header part is for simple titled cards.
Do not nest default cards inside default cards. Inside a card use ghost cards, dividers or plain groups.
A collection of similar items is a list or a table inside one card, not a grid of cards. Cards for a grid only when each item is visual (media) or the number is ≤ 6.
Padding is a prop, never ad-hoc: md 24px by default, sm 16px in dense grids, lg 32px for hero and settings cards, none when a table, list or media fills the card.
Interactive cards contain no other interactive elements (no buttons inside a link) and show a stronger edge on hover; selected cards (radio cards) carry data-selected and use the selected outline and surface tokens.
Footers hold meta or the pagination / actions row, on a hairline, on a subtle surface.
Accessibility
Interactive cards are an <a> (navigation) or a <button> (action), never a div with onClick; selectable cards are radios or checkboxes with the card as their label.
Use <section> with an aria-labelledby pointing to the title when the card is a landmark of the page.
The focus ring follows the active token on :focus-visible; the selected ring is separate and does not replace it.
Layout
Divider .cn-divideredit
A 1px gray-200 hairline that separates. Horizontal between blocks, vertical between inline controls, or the reference content divider carrying text, a heading, a button or a button group on the line, between two lines, or on a gray-50 band.
Use: Between sections of a card or form, between groups in a toolbar or menu, to label a break ("Earlier", "Or continue with") or to place an action between two blocks ("Add step"). Not between every list row or table row (those components draw their own rules), and never as decoration.
Horizontal (default)Component previewA plain <hr>: 1px gray-200 with 16px above and below.
Lumen keeps every project, task and file in one workspace your team can search.
Invite teammates, set roles and connect the tools you already use.
HTML
<div style="width:100%;max-width:480px"><p class="cn-text-body-md" style="color:var(--cn-color-fg-muted);margin:0">Lumen keeps every project, task and file in one workspace your team can search.</p><hr class="cn-divider" data-orientation="horizontal" data-variant="single" data-spacing="md"><p class="cn-text-body-md" style="color:var(--cn-color-fg-muted);margin:0">Invite teammates, set roles and connect the tools you already use.</p></div>
With textComponent preview14px medium gray-600 text on the line, 12px of air each side. Use for time breaks and "or" separators.
Sofia Almeida commented · 08:31
Earlier
Daniel Costa opened the pull request · Sep 9
HTML
<div style="width:100%;max-width:480px"><p class="cn-text-body-md" style="color:var(--cn-color-fg-muted);margin:0">Sofia Almeida commented · 08:31</p><div class="cn-divider" data-orientation="horizontal" data-variant="single" data-spacing="md" data-label="center" role="separator"><span class="cn-divider__content"><span class="cn-divider__label">Earlier</span></span></div><p class="cn-text-body-md" style="color:var(--cn-color-fg-muted);margin:0">Daniel Costa opened the pull request · Sep 9</p></div>
Heading at the start, button at the endComponent previewdata-label="start" / "end" keep a 16px stub of line before or after the content.
.cn-divider<hr> — An <hr> for a plain horizontal rule. A <div role="separator"> when vertical or when it carries content (add data-label="center|start|end").
.cn-divider__content<span> · optional — Optional slot on the line: text, a heading, a Button or a ButtonGroup. Centered, or at the start / end with data-label.
.cn-divider__label<span> · optional — Plain text inside the content slot: 14px medium fg-muted ("Earlier", "Or"). A heading uses a real h-tag with the heading-xs class instead.
Props
prop
values (default outlined)
meaning
orientation
horizontalvertical
horizontal = full-width rule between stacked blocks; vertical = 1px column that stretches to the height of a flex row (toolbars, inline meta).
variant
singledualfill
single = one hairline, with the content sitting on it (the default). dual = a hairline above and below the content row (44px with text, 56 with buttons), for section breaks that carry an action. fill = a gray-50 band (36 / 52px) holding the content, for sticky sub-headers and grouped feeds.
spacing
nonesmmdlg
Margin around the rule: none 0 (inside components that manage their own spacing), sm 8px (toolbars, dense cards), md 16px (default inside cards and forms), lg 32px (between page sections). Applies as margin-block when horizontal and margin-inline when vertical.
Rules
A divider separates two things that are both present; never end a block with one and never stack two.
Do not add dividers between rows of List, Table, DescriptionList or Accordion; they draw their own rules.
Spacing is a prop, never an ad-hoc margin: sm in toolbars and dense cards, md inside cards and forms, lg between page sections, none inside components that already space their children.
Prefer whitespace over a divider when the two blocks already differ in type (a heading followed by a paragraph needs no rule).
Text on the line is 14px medium muted, 1–3 words ("Earlier", "Or", "Today"); a heading on the line is a real h-tag in heading-xs; a labeled divider is not a section title on its own.
One action on a divider at most: an outline or ghost sm Button, or one ButtonGroup; never a primary button.
single by default; dual only when the divider carries an action between two long blocks; fill for sticky date bands in feeds.
Vertical dividers only between inline controls or meta of the same height; never taller than the row.
Always gray-200; never a stronger color, never thicker than 1px, never dashed (dashed is reserved for drop zones).
Accessibility
A plain horizontal rule is an <hr> (implicit role separator). A labeled or vertical one is a <div role="separator">; add aria-orientation="vertical" when vertical.
The label is read as text; keep it meaningful on its own ("Earlier" is fine, "———" is not).
A button or button group on the divider is focusable in the normal order; the separator role does not hide it.
Decorative dividers inside a component that already conveys grouping may use aria-hidden="true" to reduce noise.
Layout
CardHeader .cn-card-headeredit
The header row of a Card (the reference table-card header): 20 × 24px padding, a 16px semibold title with an optional gray badge, a 14px gray-600 description, an optional 40px avatar or modern featured icon before the text and the card's actions on the right, closed by a gray-200 hairline that spans the card.
Use: Use as the first child of a Card that holds a table, a list, a form section or a record. It names the card and carries its actions (Import, Add user, the "more" menu). For a page title use PageHeader; for a heading between two blocks of a page use SectionHeader. Put it in a Card with data-padding="none" so the hairline reaches the card edges.
Default (table card)Component previewCard with data-padding="none" so the header hairline spans the full width; 20 × 24px padding, 16px semibold title.
Team members
Manage your team members and their account permissions here.
24 members · 3 pending invites · Last change 2 hours ago by Daniel Costa.
HTML
<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px"><header class="cn-card-header" data-variant="default" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Team members</h2><p class="cn-card-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-card-header__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h3l1 2h3l1-2h3M2.5 9.5V4a1 1 0 011-1h9a1 1 0 011 1v5.5M2.5 9.5V12a1 1 0 001 1h9a1 1 0 001-1V9.5"/></svg><span class="cn-button__label">Import</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg><span class="cn-button__label">Add user</span></button></div></header><div class="cn-card__body" style="padding:var(--cn-space-5) var(--cn-space-6)"><p class="cn-text-body-md">24 members · 3 pending invites · Last change 2 hours ago by Daniel Costa.</p></div></section>
With badgeComponent previewA modern gray badge 8px after the title; a single ghost "more" IconButton on the right.
Team members100 users
Manage your team members and their account permissions here.
Maya Chen, Daniel Costa, Sofia Almeida and 97 others.
HTML
<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px"><header class="cn-card-header" data-variant="with-badge" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Team members<span class="cn-badge cn-card-header__badge" data-tone="neutral" data-variant="modern" data-size="sm" data-shape="square">100 users</span></h2><p class="cn-card-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-card-header__actions"><button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="More actions"><svg class="cn-icon-button__icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="12.5" cy="8" r="1.25"/></svg></button></div></header><div class="cn-card__body" style="padding:var(--cn-space-5) var(--cn-space-6)"><p class="cn-text-body-md">Maya Chen, Daniel Costa, Sofia Almeida and 97 others.</p></div></section>
With avatarComponent previewA 40px Avatar with status before the text.
Maya Chen
maya@lumen.co
Product Manager · Lisbon, Portugal · Joined March 2024.
<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px"><header class="cn-card-header" data-variant="with-tabs" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Projects</h2><p class="cn-card-header__description">Everything your team is working on right now.</p></div><div class="cn-card-header__actions"><button type="button" class="cn-button" data-variant="primary" data-size="sm"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg><span class="cn-button__label">New project</span></button></div><div class="cn-card-header__tabs"><div class="cn-tabs" data-variant="underline" data-size="sm" data-orientation="horizontal" data-width="hug"><a href="#" class="cn-tabs__tab" aria-current="page">All</a><a href="#" class="cn-tabs__tab">Active</a><a href="#" class="cn-tabs__tab">Archived</a></div></div></header><div class="cn-card__body" style="padding:var(--cn-space-5) var(--cn-space-6)"><p class="cn-text-body-md">12 active projects, 4 archived this quarter.</p></div></section>
Small with featured iconComponent previewsm padding (16 × 20) with a 40px modern gray FeaturedIcon.
Notifications
Choose how you get notified.
Email and push for mentions and approvals; in-app only for the rest.
HTML
<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px"><header class="cn-card-header" data-variant="with-media" data-size="sm"><div class="cn-card-header__media"><span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square"><svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h3l1 2h3l1-2h3M2.5 9.5V4a1 1 0 011-1h9a1 1 0 011 1v5.5M2.5 9.5V12a1 1 0 001 1h9a1 1 0 001-1V9.5"/></svg></span></div><div class="cn-card-header__content"><h2 class="cn-card-header__title">Notifications</h2><p class="cn-card-header__description">Choose how you get notified.</p></div><div class="cn-card-header__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Edit</span></button></div></header><div class="cn-card__body" style="padding:var(--cn-space-5) var(--cn-space-6)"><p class="cn-text-body-md">Email and push for mentions and approvals; in-app only for the rest.</p></div></section>
Anatomy, props, states, rules
Anatomy
.cn-card-header<header> — Wrapping flex row inside the Card: media, text block, actions; 16px gaps, aligned to the top, hairline below. Padding from size.
.cn-card-header__media<div> · optional — Optional 40px block before the text: a modern gray FeaturedIcon (md) or an Avatar md.
.cn-card-header__content<div> — Column with 2px gap: title (with optional badge) then description. Grows to push actions right.
.cn-card-header__title<h2> — 16px semibold in ink (heading-xs); a flex row so a Badge can follow the text 8px after it.
.cn-card-header__badge<span> · optional — A cn-badge (modern, gray, sm, square) inside the title after the text (add this class to the badge): "100 users", "Beta".
.cn-card-header__description<p> · optional — 14px supporting text in fg-muted.
.cn-card-header__actions<div> · optional — Row of Buttons or ghost IconButtons 12px apart; the "more" menu trigger is last.
.cn-card-header__tabs<div> · optional — Full-width slot under the text for an underline Tabs sm whose bar sits on the header hairline.
Props
prop
values (default outlined)
meaning
variant
defaultwith-badgewith-mediawith-tabs
default = title, description, actions. with-badge = a Badge after the title (count or state of the card). with-media = a 40px avatar or featured icon before the text (a person, a company, a feature). with-tabs = underline Tabs under the text switch views of the card's content.
size
smmd
md = 20px vertical, 24px horizontal padding (table cards, settings cards). sm = 16px × 20px for compact cards in a grid or a side column.
Rules
Use inside a Card with data-padding="none" and give the body its own padding, so the header hairline reaches the card edges.
Title is a noun phrase in sentence case, 1–3 words, 16px semibold; the description is one sentence in 14px muted. Never repeat the page title in a card title.
Actions: one primary Button at most, outline for the rest, ghost IconButtons for copy / more; the "more" menu is always last, 12px apart.
A Badge after the title is a modern gray sm badge with a count or a state of the card content, never decoration; one per title.
with-media: an Avatar md for a person or company, a modern gray FeaturedIcon md for a feature or setting. Never both.
with-tabs: Tabs are underline, sm, and switch views of the card's own content; their bar sits on the header hairline (the tabs lose their own line).
md padding (20 × 24) on table and settings cards; sm (16 × 20) on cards narrower than 400px. Under 768px the horizontal padding drops to 16.
Keep the header on one row when space allows. Let actions wrap below the content before the title or description becomes a narrow column.
Accessibility
Root is a <header> inside the Card <section>; the title heading level follows the page outline (h2 under a PageHeader h1).
A Badge inside the title becomes part of the heading name; keep it short and meaningful.
IconButtons in actions need aria-label; the "more" trigger exposes aria-haspopup="menu".
Tabs under the header are route links with aria-current="page" or a role="tablist" with aria-selected.
The featured icon is decorative (aria-hidden); an Avatar whose name is the title is aria-hidden too, so the name is not read twice.
Layout
ContentDivider .cn-content-divideredit
A hairline that carries content: body-sm text centered or at the start, an outline sm Button, a 24px icon, or a pill chip in the middle of the line. The richer sibling of Divider for long content, threads, auth forms and load-more rows.
Use: Use where a plain rule is not enough: "Or continue with" on sign-in, "Earlier this week" in a feed, "Show 12 more comments" in a thread, "3 new messages" in a chat. For a plain rule or a kicker-voice label use Divider; for a section heading use SectionHeader.
PlainComponent preview
Maya approved the launch plan and asked for a final read of the pricing page.
Daniel uploaded the updated timeline.
HTML
<div style="width:100%;max-width:560px"><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Maya approved the launch plan and asked for a final read of the pricing page.</p><div class="cn-content-divider" data-variant="plain" data-spacing="md" role="separator"><span class="cn-content-divider__line" aria-hidden="true"></span></div><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Daniel uploaded the updated timeline.</p></div>
Text (centered)Component previewThe auth-form separator.
Text at the startComponent previewGroup headings in feeds and inboxes; the line starts after the label.
Earlier this week
Sofia assigned the homepage redesign to Lucas · Tuesday
HTML
<div style="width:100%;max-width:560px"><div class="cn-content-divider" data-variant="text-left" data-spacing="sm" role="separator"><span class="cn-content-divider__label">Earlier this week</span><span class="cn-content-divider__line" aria-hidden="true"></span></div><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Sofia assigned the homepage redesign to Lucas · Tuesday</p></div>
Button (load more)Component previewThe button is the only interactive thing on the line; no role on the root.
Aisha: I set up the A/B test for Monday.
Maya: The second headline reads better.
HTML
<div style="width:100%;max-width:560px"><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Aisha: I set up the A/B test for Monday.</p><div class="cn-content-divider" data-variant="button" data-spacing="md"><span class="cn-content-divider__line" aria-hidden="true"></span><div class="cn-content-divider__button"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg><span class="cn-button__label">Show 12 more comments</span></button></div><span class="cn-content-divider__line" aria-hidden="true"></span></div><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Maya: The second headline reads better.</p></div>
IconComponent previewA quiet break inside an article; lg spacing.
That was the last release of the quarter, and the first one where every workspace shipped on the same day.
What comes next starts with the reporting workspace.
HTML
<div style="width:100%;max-width:560px"><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">That was the last release of the quarter, and the first one where every workspace shipped on the same day.</p><div class="cn-content-divider" data-variant="icon" data-spacing="lg" role="separator"><span class="cn-content-divider__line" aria-hidden="true"></span><span class="cn-content-divider__icon" aria-hidden="true"><svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg></span><span class="cn-content-divider__line" aria-hidden="true"></span></div><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">What comes next starts with the reporting workspace.</p></div>
PillComponent previewUnread marker in a chat: the chip names what is below the line.
Tomás: Sending the revised quote now.
3 new messages
Elena: Got it, reviewing with finance.
HTML
<div style="width:100%;max-width:560px"><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Tomás: Sending the revised quote now.</p><div class="cn-content-divider" data-variant="pill" data-spacing="sm" role="separator"><span class="cn-content-divider__line" aria-hidden="true"></span><span class="cn-content-divider__pill">3 new messages</span><span class="cn-content-divider__line" aria-hidden="true"></span></div><p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">Elena: Got it, reviewing with finance.</p></div>
Anatomy, props, states, rules
Anatomy
.cn-content-divider<div> — Flex row, centered, 16px gaps; role="separator" when it only carries text, no role when it holds a button.
.cn-content-divider__line<span> — The 1px border-default rule that fills the free space (flex 1). aria-hidden. One on each side, or one after the label in text-left.
.cn-content-divider__label<span> · optional — body-sm muted text on the line ("Or continue with", "Earlier this week").
.cn-content-divider__button<div> · optional — Wrapper for an outline sm Button in the middle ("Show 12 more comments").
.cn-content-divider__icon<span> · optional — 24px icon in fg-subtle on the line, for a purely visual break.
.cn-content-divider__pill<span> · optional — label-sm chip in bg-surface with a hairline, radius full, 28px tall ("3 new messages").
Props
prop
values (default outlined)
meaning
variant
plaintexttext-leftbuttoniconpill
plain = the line only (same as Divider, kept here for consistency inside content that mixes variants). text = centered body-sm label between two lines. text-left = the label at the start followed by one line (group headings in feeds). button = an outline sm Button between two lines (load more, show earlier). icon = a 24px icon between two lines (a decorative break in an article). pill = a chip between two lines (unread markers, "new since your last visit").
spacing
smmdlg
Vertical margin: sm 16px inside cards and threads, md 24px between blocks of content, lg 40px between sections of an article.
Rules
A content divider separates two things that are both present and adds one piece of information about the break; if there is nothing to say, use Divider.
Text labels are 2–4 words in sentence case, body-sm muted: a time ("Earlier this week"), an alternative ("Or continue with"), a count ("3 new messages"). Never a heading.
One element in the middle at most: text, a button, an icon or a pill. Never a button and text together.
The button variant is for revealing more of the same content ("Show 12 more comments", "Load earlier messages"); it is an outline sm Button and the count is in its label.
Pills mark a boundary the user must notice (unread, new since last visit) and disappear once the content below is seen.
Icons are decorative breaks in long-form reading; one per article at most, always aria-hidden.
Spacing is a prop: sm in cards and threads, md between blocks, lg between article sections. Never add margins around it.
Do not put content dividers between List, Table or Timeline rows; those components draw their own rules.
Accessibility
When the divider only carries text or an icon, the root is role="separator" and the lines are aria-hidden; the label is read as the separator's name.
When it carries a Button, the root has no role: the button is a normal control in the tab order and the lines stay aria-hidden.
Labels must be meaningful on their own ("Earlier this week" is fine; a lone dash is not).
A pill that marks unread content also updates an aria-live region or the page title so the count is announced.
Layout
PageHeader .cn-page-headeredit
Top of an application page: title, supporting text and the actions for the page, optionally with breadcrumbs above, a search field, an avatar, or tabs below. The "page header" pattern.
Use: Exactly one per page, directly under the top navigation. Put the page-level primary action here (at most one filled button). Section titles inside the page use SectionHeader.
SimpleComponent preview
Team members
Manage your team members and their account permissions here.
HTML
<header class="cn-page-header" data-variant="simple" data-divider="yes"><div class="cn-page-header__content"><h1 class="cn-page-header__title">Team members</h1><p class="cn-page-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-page-header__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Secondary</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg><span class="cn-button__label">Add member</span></button></div></header>
All the latest the system updates, improvements, and fixes.
HTML
<header class="cn-page-header" data-variant="centered" data-divider="no"><div class="cn-page-header__content"><h1 class="cn-page-header__title">Release notes</h1><p class="cn-page-header__description">All the latest the system updates, improvements, and fixes.</p></div></header>
.cn-page-header__crumbs<nav> · optional — Optional breadcrumb row above the title (uses Breadcrumb markup).
.cn-page-header__media<span> · optional — Optional 56px avatar or featured icon before the content.
.cn-page-header__content<div> — Title + description column.
.cn-page-header__title<h1> — heading-lg (30/38 semibold). One h1 per page.
.cn-page-header__description<p> — body-lg (16/24) in fg-muted. One sentence.
.cn-page-header__actions<div> — Right-aligned row of buttons; on mobile it stacks under the content.
.cn-page-header__search<div> · optional — Optional search Input in the actions row (width 320).
.cn-page-header__tabs<div> · optional — Optional Tabs (underline) below, bleeding to the header edges.
Props
prop
values (default outlined)
meaning
variant
simplewith-searchwith-tabswith-avatarcentered
simple = title/description/actions; with-search = a search field before the actions; with-tabs = tabs under the header; with-avatar = 56px media block before the title (profile pages); centered = title and description centered, no actions (informational pages).
divider
yesno
yes = 1px hairline under the header (or under the tabs).
Rules
The title is the h1 and matches the navigation item that brought the user here.
Description is optional and never longer than one sentence.
At most one primary button; secondary actions are outline; overflow goes into a ⋯ IconButton with a Menu.
20px below the header (the padding) then 32px to the first section when there is a divider; with tabs, the tab list sits on the divider line.
On mobile (< 768) actions move under the content and stretch full width.
Accessibility
One h1 per page; the breadcrumb nav has aria-label="Breadcrumb".
Search fields have an aria-label when the placeholder is the only label.
Layout
SectionHeader .cn-section-headeredit
Heading row of a section inside a page or a card: title, supporting text, optional badge, tabs or search, and section-level actions. Smaller than PageHeader.
Use: Above tables, forms, lists and settings groups. Use inside Card as its header row when the card holds a data set. Do not use for the page title (PageHeader).
DefaultComponent preview
Personal info
Update your photo and personal details here.
HTML
<div class="cn-section-header" data-variant="default" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Personal info</h2><p class="cn-section-header__description">Update your photo and personal details here.</p></div><div class="cn-section-header__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Cancel</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Save</span></button></div></div>
With badgeComponent preview
Team members 100 users
Manage your team members and their account permissions here.
HTML
<div class="cn-section-header" data-variant="with-badge" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Team members <span class="cn-section-header__badge cn-badge" data-tone="accent" data-variant="soft" data-size="sm">100 users</span></h2><p class="cn-section-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-section-header__actions"><button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Download all</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Add user</span></button></div></div>
<section class="cn-card" data-variant="default" data-padding="none" style="max-width:640px"><div class="cn-section-header" data-variant="card" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Notifications</h2><p class="cn-section-header__description">Choose what you want to be notified about.</p></div><div class="cn-section-header__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Reset</span></button></div></div><div class="cn-card__body" style="padding:var(--cn-space-6)"><p class="cn-text-body-md">Card content</p></div></section>
Anatomy, props, states, rules
Anatomy
.cn-section-header<div> — Flex row with a bottom hairline (divider prop).
.cn-section-header__content<div> — Title + description column.
.cn-section-header__title<h2> — heading-sm (18/28 semibold) with optional inline badge.
.cn-section-header__badge<span> · optional — Optional count/status badge after the title (Badge markup).
.cn-section-header__description<p> — body-md in fg-muted.
.cn-section-header__actions<div> — Right-aligned buttons (sm or md).
.cn-section-header__tabs<div> · optional — Optional Tabs (pill/button variant) in the actions slot or below.
.cn-section-header__search<div> · optional — Optional search Input (width 280) in the actions slot.
Props
prop
values (default outlined)
meaning
variant
defaultwith-badgewith-tabswith-searchcard
default; with-badge = count/status next to the title; with-tabs = filter tabs on the right; with-search = search input on the right; card = padding for use as a Card header (20/24px) with the divider.
divider
yesno
Hairline under the header.
Rules
Title is an h2 (or h3 inside a card that already has an h2). Never a page title.
Description one sentence; omit rather than repeat the title.
Actions on the right are sm inside cards, md on the page. At most one primary.
20px padding below, hairline divider, then 24px (space.6) to the content.
Filter tabs use role="tablist" only when they switch panels; otherwise use a SegmentedControl.
Media
MediaFrame .cn-media-frameedit
A figure that holds an image or video at a fixed aspect ratio: a clipping frame with a subtle fill, an optional hairline, an optional flat text overlay, and a caption below. The frame clips; the figure does not, so captions never get cut.
Use: Use for every image or video in the product: company logos in cards, screenshots in help articles, thumbnails in lists, a video in an empty state. Never place a raw <img> in a layout; the frame guarantees the ratio, the loading placeholder and the radius.
.cn-media-frame<figure> — The figure. Sets width; overflow visible so the caption sits below. data-ratio / data-radius / data-fit, optional data-bordered, data-state="loading".
.cn-media-frame__frame<div> — The clipping box: aspect-ratio, radius, subtle fill, overflow hidden. Holds media and overlay.
.cn-media-frame__media<img> — The <img>, <video> or inline <svg>. Fills the frame; object-fit from data-fit. Always has alt (or aria-label for svg).
.cn-media-frame__overlay<div> · optional — Optional flat scrim over the media with inverse text at the bottom-left: a title, a duration, a label. No gradient.
.cn-media-frame__caption<figcaption> · optional — body-xs muted line under the frame: source, date, what the image shows.
Props
prop
values (default outlined)
meaning
ratio
auto1:14:33:216:9
Aspect ratio of the frame. auto = the media’s own height (logos, inline illustrations); 1:1 = avatars, product shots; 4:3 = documents and screenshots; 3:2 = photos; 16:9 = video and hero thumbnails (default).
radius
nonemdxl2xl
Corner radius of the frame. none = full-bleed inside a card with padding none; md = small thumbnails in rows; xl = default for cards and articles; 2xl = hero media on marketing pages.
fit
covercontain
cover = fills the frame and crops (photos, screenshots); contain = shows the whole media on the subtle fill (logos, diagrams).
States
state
selector
meaning
loading
[data-state="loading"]
Media not yet available: the frame shows the skeleton shimmer and the media is hidden. (data-state="loading" on the figure)
bordered
[data-bordered]
Adds the default hairline around the frame, for light media on white surfaces (logos, screenshots). (data-bordered attribute on the figure)
Rules
Every image and video in the product lives in a MediaFrame; a raw <img> in a layout is a lint error.
Pick the ratio from the content, not the space: 1:1 logos and avatars, 4:3 screenshots, 3:2 photos, 16:9 video. Do not stretch a screenshot into 16:9.
Width comes from the layout (100% of its column); set it inline only in isolated thumbnails.
Logos and diagrams use fit="contain" and data-bordered; photos use cover without a border.
The overlay is flat (bg-overlay) with one line of label-md inverse text at the bottom-left. No gradients, no blur, no second line.
Captions are body-xs muted, one line, below the frame: source, date, or what the picture shows. They are not titles.
Radius matches the container: md in rows, xl in cards, 2xl on marketing heroes, none when the media bleeds to the card edge.
Show data-state="loading" only for network images; inline SVGs render immediately.
Accessibility
Every <img> has alt text that says what it shows; decorative images use alt="" and the figure gets no caption.
Inline SVG media uses role="img" with aria-label; videos have captions or a transcript.
The figcaption is the accessible description; do not repeat the alt text in it.
Overlay text keeps AA contrast on the scrim (inverse text on bg-overlay); never place text over unscrimmed media.
Media
Carousel .cn-carouseledit
A clipped viewport (radius card) over a flex track of slides, with 40px round prev/next controls that sit inside the picture or in a row under it, 8px dot or 24px line indicators, and an optional caption on a scrim. The component is static; the app moves the track.
Use: Use for a small set of visual items where one at a time is enough: product screenshots, gallery photos, customer quotes, onboarding cards. Not for content people need to compare (use a grid), not for navigation, and never auto-playing text.
Single with captionRound controls float over the edges; the caption sits on a scrim and leaves room for the dots. Previous is disabled on the first slide.
Lumen Studio · the new reporting workspace, September 2026
TestimonialA centered quote card per slide; dots only, swipe to move.
“We replaced four spreadsheets with one Lumen dashboard. The finance review went from a day to forty minutes.”
Elena RossiHead of Finance, Northwind Freight
“Approvals on the phone changed how our field team works.”
Tomás SilvaOperations Lead, Verde Farms
“Onboarding a new analyst takes an afternoon now.”
Aisha KhanData Lead, Orbit Health
HTML
<div class="cn-carousel" data-variant="testimonial" data-indicators="dots" data-controls="none" role="region" aria-roledescription="carousel" aria-label="Product tour" style="width:100%;max-width:640px"><div class="cn-carousel__viewport"><div class="cn-carousel__track"><div class="cn-carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3"><p class="cn-carousel__quote">“We replaced four spreadsheets with one Lumen dashboard. The finance review went from a day to forty minutes.”</p><span class="cn-carousel__author">Elena Rossi</span><span class="cn-carousel__meta">Head of Finance, Northwind Freight</span></div><div class="cn-carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3"><p class="cn-carousel__quote">“Approvals on the phone changed how our field team works.”</p><span class="cn-carousel__author">Tomás Silva</span><span class="cn-carousel__meta">Operations Lead, Verde Farms</span></div><div class="cn-carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3"><p class="cn-carousel__quote">“Onboarding a new analyst takes an afternoon now.”</p><span class="cn-carousel__author">Aisha Khan</span><span class="cn-carousel__meta">Data Lead, Orbit Health</span></div></div><div class="cn-carousel__indicators" role="group" aria-label="Choose slide"><button type="button" class="cn-carousel__indicator" aria-label="Go to slide 1" aria-current="true"></button><button type="button" class="cn-carousel__indicator" aria-label="Go to slide 2"></button><button type="button" class="cn-carousel__indicator" aria-label="Go to slide 3"></button></div></div></div>
Lines, no controls24×4 line indicators for a short onboarding sequence; the second slide is current.
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
multi
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
testimonial
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
Lumen Studio · the new reporting workspace, September 2026
Anatomy, props, states, rules
Anatomy
.cn-carousel<div> — The region (role="region", aria-roledescription="carousel"). Relative; stacks the viewport and, for controls="outside", the control row.
.cn-carousel__viewport<div> — Clips the track: overflow hidden, radius card, bg-subtle. Controls, indicators and caption overlay it.
.cn-carousel__track<div> — Flex row of slides that the app translates (transform) to move between them.
.cn-carousel__slide<div> — One item (role="group", aria-roledescription="slide", aria-label "2 of 5"). 100% wide in single, a third in multi, a centered quote card in testimonial.
.cn-carousel__controls<div> · optional — Holds the two control buttons: absolutely centered over the viewport edges (inside) or a static right-aligned row under it (outside).
.cn-carousel__control<button> · optional — 40px circle, bg-surface, hairline, shadow-md, 20px chevron. aria-label "Previous slide" / "Next slide"; disabled at the ends when not looping.
.cn-carousel__indicators<div> · optional — Centered row of indicator buttons 16px above the bottom edge (role="group").
.cn-carousel__indicator<button> · optional — 8px dot (or 24×4 line) in bg-muted; the current one is bg-action and carries aria-current="true".
.cn-carousel__caption<div> · optional — Bottom-left text over a bg-inverse scrim (color-mix 60%), body-sm in fg-inverse. Leaves room for the indicators.
.cn-carousel__author<span> · optional — Who said it, label-md, under the quote.
.cn-carousel__meta<span> · optional — Role and company, body-sm muted.
Props
prop
values (default outlined)
meaning
variant
singlemultitestimonial
single = one full-width slide at a time (screenshots, photos). multi = three cards visible with a 16px gap, the track scrolls by one card (feature cards, logos). testimonial = each slide is a centered quote card on bg-subtle with author and role.
indicators
dotslinesnone
dots = 8px circles (up to about 8 slides). lines = 24×4 bars, for 2–5 slides on marketing pages. none = hide the indicators when the controls or the caption already tell the position.
controls
insideoutsidenone
inside = round buttons floating over the left and right edges of the picture (photos, screenshots). outside = a row of the same buttons under the viewport, right-aligned, so nothing covers the content (cards, quotes). none = swipe and indicators only (mobile, testimonials).
States
state
selector
meaning
current
.cn-carousel__indicator[aria-current="true"]
The indicator of the visible slide (on the indicator; extraCss): bg-action. (aria-current="true" on .cn-carousel__indicator)
controlHover
.cn-carousel__control:hover
Pointer over a control (extraCss): bg-subtle fill, stronger border. (native :hover on .cn-carousel__control)
controlFocus
.cn-carousel__control:focus-visible
Keyboard focus on a control adds the focus ring under the shadow (extraCss). (native :focus-visible on .cn-carousel__control)
controlDisabled
.cn-carousel__control:disabled
At the first or last slide of a non-looping carousel: 50% opacity, not-allowed (extraCss). (disabled on .cn-carousel__control)
Rules
Three to eight slides. With one there is nothing to move; with more than eight use a grid with pagination.
Never auto-play. If a marketing page insists, pause on hover and focus, stop after one loop, and offer a pause button.
Controls inside only over pictures with quiet edges; over text or cards use outside so nothing is covered.
Disable Previous on the first slide and Next on the last unless the carousel loops; show both buttons always.
Indicators are dots up to eight slides, lines for two to five on marketing pages; hide them when a caption or counter already shows the position.
Captions are one line of body-sm on the scrim, bottom-left; longer descriptions go under the carousel as text.
Slides keep one aspect ratio across the set (16:9 by default); never let the height jump between slides.
The viewport is the only element with overflow hidden; the track moves with transform, 260ms, and respects reduced motion.
On touch, swipe moves one slide; on keyboard, Left/Right move when the region has focus.
Accessibility
Root: role="region", aria-roledescription="carousel", aria-label naming the set. Slides: role="group", aria-roledescription="slide", aria-label "2 of 5".
Controls are real <button>s with aria-label "Previous slide" / "Next slide"; indicators are buttons with aria-label "Go to slide n" and aria-current="true" on the current one.
Hidden slides are aria-hidden="true" and their links get tabindex="-1" so keyboard users do not tab into off-screen content.
Announce slide changes with an aria-live="polite" region ("Slide 2 of 5") rather than moving focus.
Images need alt text or, for decorative art, alt=""; captions that describe the slide can serve as the accessible name via aria-labelledby.
Media
CreditCard .cn-credit-cardedit
A decorative payment card at the real 1.586 ratio: logo and chip on top, a masked number in the middle, holder and expiry at the bottom next to a generic network mark. Four finishes (brand, dark, light, outline) and two sizes.
Use: Use in billing settings, checkout summaries and the payment-method picker to show which saved card is in use. It is an illustration of a record, not a form: editing happens in a Dialog with real Inputs. Never render a full card number.
Brand (default)The product card: action fill, on-action text, a quiet circle in the corner.
.cn-credit-card<div> — The card: 316px wide (256 in sm), aspect-ratio 1.586, radius 2xl, 24px padding, flex column with the number centered vertically. role="img" with an aria-label that names the card.
.cn-credit-card__header<div> — Top row: logo on the left, chip on the right.
.cn-credit-card__logo<span> — Issuer or product wordmark, label-md semibold with a 20px mark.
.cn-credit-card__chip<svg> — The 32px contact chip drawn in currentColor at reduced opacity.
.cn-credit-card__number<div> — The masked number "•••• •••• •••• 4242" in numeric-md, tabular figures and wider tracking.
.cn-credit-card__footer<div> — Bottom row: holder block on the left, network mark on the right.
.cn-credit-card__holder<div> — Name and expiry side by side, each under a tiny caption.
.cn-credit-card__caption<span> — Kicker-voice caption above name and expiry ("Card holder", "Expires").
.cn-credit-card__name<span> — Cardholder name in label-sm, wide tracking, truncated.
.cn-credit-card__expiry<span> — MM/YY in numeric-md.
.cn-credit-card__brand<svg> — A generic 40px network mark (two overlapping circles in currentColor). Replace with the real network logo when known.
Props
prop
values (default outlined)
meaning
variant
branddarklightoutline
brand = action-colored card with on-action text, the default card of the product. dark = inverse surface (ink in light theme, near-white in dark theme). light = surface with a hairline and shadow-xs, for lists of several cards. outline = transparent with a strong hairline, the "add a card" or placeholder look.
size
smmd
md = 316px wide, 24px padding, 16px number (settings pages, checkout). sm = 256px, 16px padding, 13px number for pickers, drawers and mobile.
States
state
selector
meaning
selected
[aria-selected="true"], &[data-selected]
The chosen card in a picker: a 2px action-colored ring outside the card. (aria-selected="true" (inside a listbox) or data-selected on the root)
Rules
Decorative: the card is a picture of a saved payment method, never a form. Editing and adding cards happen in a Dialog with Inputs.
Always mask the number as "•••• •••• •••• 4242": the last four digits only, never more, never the CVC, never an expiry that is not needed on screen.
One brand card per screen; other saved cards use light or outline so the default method stands out.
Keep the 1.586 ratio and the 316/256 widths; never stretch the card to a column. Center it or align it with the form above.
The network mark is generic until the network is known; then use the real mark at 40px, never a colored badge.
Names are shown as stored, truncated with an ellipsis after 160px; expiry is MM/YY.
No gradients, foils or photos; the only decoration is the 8% circle in the corner.
The card never opens anything on click by itself. In a picker, wrap it in a listbox option and use data-selected for the ring.
Accessibility
The root has role="img" and an aria-label that says it all: "Lumen card ending in 4242, Maya Chen, expires 09/29". The inner text is then decorative.
When the card is an option in a picker, the option element carries the name and aria-selected; the card inside stays role="img".
Contrast: on-action text on the action fill and fg-inverse on bg-inverse are AA by token design; the light and outline variants use fg-default.
Do not expose the number as a table of digits; screen readers read the masked string with the last four digits.
Media
FeaturedIcon .cn-featured-iconedit
A compact icon frame with a leading edge and a clipped corner. Introduces a dialog, an empty state or a meaningful section.
Use: Use at the top of a dialog, an empty state, a feature card or a step. One per block. Never as a decorative bullet in lists and never next to a title inside a table row (use a plain 20px icon there).
.cn-featured-icon<span> — The container. Size, radius and theme live here.
.cn-featured-icon__icon<svg> — The icon, sized by the container size (16/20/24/28). Stroke 1.5, currentColor.
Props
prop
values (default outlined)
meaning
theme
lightdarkmodernoutline
outline = a fine frame with a stronger leading edge (default); light = a quiet tinted surface; dark = solid fill with contrasting icon; modern = a neutral surface with a fine border.
tone
brandgraysuccesswarningerror
Color family. gray for neutral/system, success/warning/error for status dialogs.
square = compact corners with a clipped upper corner; circle = an optional round silhouette. Use square throughout a view.
Rules
One featured icon per block, always above or left of a title. It introduces; it never decorates.
Dialogs: lg (48px) light or outline; empty states: lg/xl; feature cards on marketing pages: md/lg light or dark; alerts: sm outline frame.
Tone follows meaning: brand for neutral product actions, error for destructive confirmations, success for completed, warning for caution, gray for system/info.
The icon is always an outline icon from the set with stroke 1.5 and currentColor. Never emoji, never a filled icon.
Do not put featured icons in table rows, list items or badges.
Accessibility
Decorative: the icon carries aria-hidden and the title next to it names the content.
If it must convey a status alone (rare), add role="img" and aria-label to the root.
Media
VideoPlayer .cn-video-playeredit
A 16:9 inverse frame with a poster, a centered 64px play button on a translucent surface circle, and a bottom control bar on a scrim: a 2px progress line with a scrubber, 32px icon buttons in fg-inverse and a mono time label. Three looks: default, minimal (play only) and mockup (a framed device-like shot for marketing).
Use: Use for product videos, onboarding clips and recorded sessions inside the app or on marketing pages. The component styles the chrome; the <video> element and its behavior come from the app. For images use MediaFrame; for a slideshow use Carousel.
Default (paused)Poster, centered play button and the full bar. The progress line is 2px and grows on hover.
.cn-video-player<div> — The frame: relative, 16:9, bg-inverse, radius card, overflow hidden. data-state="paused|playing". Max width from size.
.cn-video-player__media<video> — The <video> (with poster) or an <img>, absolutely filling the frame with object-fit cover. In the gallery an inline svg poster.
.cn-video-player__play<button> — Centered 64px circle, bg-surface at 90%, shadow-lg, with a 24px play icon. Hidden while playing.
.cn-video-player__controls<div> — Bottom bar on a scrim (bg-inverse at 70%): the progress line, then the row of buttons and time.
.cn-video-player__progress<div> — 2px full-width track (fg-inverse at 30%), role="slider" for seeking; grows to 4px on hover.
.cn-video-player__progress-fill<div> — The played portion in fg-inverse; width is the inline percentage.
.cn-video-player__scrubber<span> — 12px round handle at the end of the fill (left = the same percentage).
.cn-video-player__row<div> — The controls row: a left group (play, volume, time) and a right group (captions, settings, full screen).
.cn-video-player__group<div> — A cluster of buttons with a 4px gap.
.cn-video-player__button<button> — 32px icon button in fg-inverse, radius md, translucent hover. aria-label required; captions is a toggle with aria-pressed.
.cn-video-player__time<span> — Elapsed / total in code-sm fg-inverse, tabular ("0:42 / 3:18").
Props
prop
values (default outlined)
meaning
variant
defaultminimalmockup
default = play button plus the full control bar (in-app video). minimal = poster and play button only; controls appear once playing (embeds in cards and docs). mockup = the default chrome inside a framed, shadowed device-like shot for marketing hero sections; never inside the app shell.
size
smmdlg
Maximum width of the frame: sm 480px (cards, side panels), md 720px (content pages), lg 960px (marketing sections). Always 100% wide below that.
States
state
selector
meaning
playing
[data-state="playing"]
Media is playing: the centered play button hides and the bar shows a pause icon. The app toggles the attribute. (data-state="playing" on the root)
paused
[data-state="paused"]
Media is paused or not started: the centered play button is visible over the poster. (data-state="paused" on the root (default))
playHover
.cn-video-player__play:hover
Pointer over the big play button (extraCss): the circle becomes fully opaque. (native :hover on .cn-video-player__play)
buttonHover
.cn-video-player__button:hover
Pointer over a bar button (extraCss): a translucent fg-inverse fill. (native :hover on .cn-video-player__button)
buttonFocus
.cn-video-player__button:focus-visible
Keyboard focus on a bar button or the play button shows the focus ring (extraCss). (native :focus-visible on .cn-video-player__button)
buttonPressed
.cn-video-player__button[aria-pressed="true"]
A toggle such as captions that is on (extraCss): keeps the translucent fill at rest. (aria-pressed="true" on .cn-video-player__button)
Rules
Always 16:9 unless the source is vertical; the frame keeps its ratio while loading so the page does not jump.
Show a real poster frame, never a black rectangle; the big play button is the only overlay on it.
The bar holds at most six controls: play/pause, volume, time on the left; captions, settings, full screen on the right. Extra features go in the settings menu.
The time label is "elapsed / total" in the mono style with tabular figures; show a live stream as "LIVE" with a red dot instead.
Hide the bar 3 seconds after the pointer leaves while playing; show it on hover, focus or touch. Never hide it while paused.
Never autoplay with sound. Autoplay muted only for decorative loops on marketing pages, with a visible play/pause control.
sm inside cards and side panels, md on content pages, lg in marketing sections; mockup only on marketing pages.
Captions are a toggle (aria-pressed) and default to on when the viewer's system prefers captions.
Accessibility
Root is a labelled region; the <video> has a title and, for real content, captions (WebVTT) and an audio description track when available.
Every control is a <button> with an aria-label; play/pause changes its label with the state and captions uses aria-pressed.
The progress line is role="slider" with aria-valuemin/max/now and a text alternative for the time; Left/Right seek 5 seconds, Home/End jump.
Keyboard: Space or K toggles play, M mutes, F enters full screen, C toggles captions; Escape leaves full screen.
Focus rings stay visible on the dark chrome (the ring color is independent of the theme); never remove outline without the box-shadow ring.
Application layouts
App shell
Top navigation bar with 4–5 tabs phrased as user questions, a page header (kicker + title + one primary action), and a content area with a max width. No left sidebar by default.
Page header: kicker (mono uppercase) above the h1 (heading-xl). Actions on the right: at most one primary button plus ghost/outline buttons.
Content max width 1152px (size.container.xl), 24px side padding, 32px between page header and body.
Sections inside the body are separated by 32px (space.8); groups inside a section by 16px (space.4); rows inside a group by 8px (space.2).
Detail views open in a Drawer over the current list; do not navigate to a separate page for one item.
Application sections
Form layout
Vertical form: one column, labels above fields, 16px between fields, sections separated by a divider with a heading, actions at the end aligned right.
Product previewPrimary layout
HTML
<form class="cn-form" style="max-width:520px"><section class="cn-form__section"><h3 class="cn-form__title">Empresa</h3><p class="cn-form__description">Como a Vera deve se apresentar.</p><div class="cn-field"><label class="cn-field__label" for="f1">Nome da empresa</label><div class="cn-input" data-variant="default" data-size="md"><input id="f1" class="cn-input__field" type="text" placeholder="Vero Finance"></div><p class="cn-field__helper">Como aparece na Receita Federal.</p></div><div class="cn-form__row"><div class="cn-field"><label class="cn-field__label" for="f2">Cidade</label><div class="cn-input" data-variant="default" data-size="md"><input id="f2" class="cn-input__field" type="text"></div></div><div class="cn-field"><label class="cn-field__label" for="f3">UF</label><div class="cn-input" data-variant="default" data-size="md"><input id="f3" class="cn-input__field" type="text" maxlength="2"></div></div></div></section><div class="cn-form__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Cancelar</span></button><button type="submit" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Salvar</span></button></div></form>
Layout guidelines
One column. Two columns only for short related pairs (city/state, first/last name) using .cn-form__row.
Field order: label → control → helper or error. Helper and error never show at the same time.
Required is the default; mark optional fields with "(optional)" in the label instead of asterisks.
Gap between fields 16px (space.4); between sections 32px (space.8) with a section title (heading-sm) and optional description.
Actions: right-aligned, ghost "Cancel" then primary. In a dialog they live in the dialog footer; in a page they are sticky at the bottom only when the form is long.
Max width of a form column: 640px (size.container.sm).
Application pages
Audit log and activity
A chronological record of who did what: a dense table with actor, action, target and metadata and expandable detail rows; a timeline view; and a security-events variant with severity.
Product previewPrimary layout
Audit log
Every change in your workspace, kept for 400 days.
1,204 events
Actor
Event
Category
When
MCMaya Chen
updatedBilling settings· Changed payment method to Visa •••• 4242
settings
Today 09:41
Event ID
evt_01J8Z3K2QWERTY
IP address
187.34.12.9 · Florianópolis, BR
User agent
Chrome 129 · macOS
Before → After
Mastercard •••• 0913 → Visa •••• 4242
DCDaniel Costa
deployedBilling webhook v2· Production · 2 migrations
Filters: search, actor, category and a date range on the left; count and Export on the right. Filters never hide the table.
Rows are compact (48px): actor (avatar xs + name), action in plain words, target in label-sm, a category badge, timestamp right-aligned in body-sm muted; important rows can expand to a detail block (key/value list) below the row.
Timeline variant groups by day with a vertical line and dots; the same fields, more room for descriptions.
Security events use severity badges (danger/warning/neutral) and a “Review” action; never color entire rows.
Application pages
Billing and checkout
Plan and usage, payment method, billing history; a plan change with a comparison; and a checkout form with an order summary.
Product previewPrimary layout
Billing
Manage your plan, payment method and invoices.
Your last payment failed
We couldn’t charge the Visa ending in 4242 on 12 September. Update your payment method to keep your plan active.
Business plan Monthly
Renews on 12 October 2026
$20 per user / month14 of 20 seats used
Payment method
Change how you pay for your plan.
VISA
Visa ending in 4242Expiry 06/2028 · billing@lumen.co
Billing page: plan card (name, renewal, price per seat, seat usage bar), payment method card (brand mark, last 4, expiry, Default badge), invoices table (number, date, status badge, amount right-aligned, download link).
Failed payments show a danger Alert at the top with a single "Update payment method" action.
Checkout: two columns — form (contact, card number with brand icon, expiry + CVC in a row, country) on the left, an order summary card on the right (line items, subtotal, tax, total in heading-md); the primary button names the amount ("Pay $280.00").
Never mask everything: show the last 4 digits and the brand; never ask for the CVC outside the payment step.
Application pages
Calendar and scheduling
Month grid with event chips, week view with time blocks, and an agenda list with an event detail card.
Header: month/year title with prev/next icon buttons and a "Today" outline button; view switch (Month / Week / Day) as a segmented control; "New event" is the primary action.
Month grid: 7 columns, cells ≥ 120px tall, day number top-left (label-sm), events as small chips (body-xs, 4px radius, tinted by category); outside-month days muted; today has a brand-filled day number.
Week view: time column + 5–7 day columns, 1-hour rows (64px), events as blocks with a tinted background and left border; overlapping events share the column.
Agenda: list grouped by day; each item time (code-sm) + title + attendees avatars; clicking opens the detail card (title, time, location, attendees, actions).
Application pages
Dashboard
Application home: sidebar, page header with the primary action, a metrics row, a chart card with an activity list, and a data table card. Three layouts.
Product previewPrimary layout
Welcome back, Maya
Track, manage and forecast your customers and orders.
Total customers
2,420
+40%
vs last month
Members
1,210
+10%
vs last month
Active now
316
−2%
vs last month
Conversion
4.2%
+0.4%
vs last month
Revenue
Monthly recurring revenue, last 12 months
$128,430This yearLast year
Recent activity
MCMaya Chencommented on Q3 roadmap2m
DCDaniel Costamerged “Billing refactor”14m
SASofia Almeidauploaded 3 files to Brand1h
LFLucas Ferreirainvited Elena Rossi3h
Team members 100 users
Manage your team members and their account permissions here.
Structure: .cn-app (flex) → Sidebar + .cn-app__main (32px padding, 32px gap between blocks). Use the Sidebar component’s current width and navigation density.
Order: page header → metrics (4 stat cards, 24px gap) → chart + activity (2:1 grid) → table card with its own header and pagination footer.
Exactly one primary button on the page (in the header); card headers use outline/link buttons.
Charts live inside a card with a section header (title, description, period tabs) and a legend; no 3D, no gradients, brand color for the main series and brand-300 for the secondary.
Below 1024px the sidebar hides (use the MobileHeader) and grids collapse: metrics 2×2, chart and activity stacked.
Application pages
Files and documents
A file manager: breadcrumbs, toolbar (search, filters, list/grid toggle), a selectable table of files with owner and size, or a grid of file cards with previews; an upload dropzone and a details drawer.
Breadcrumbs above the page title show the folder path (max 3 levels shown, the rest collapsed).
Toolbar: search (320) + filter buttons on the left, list/grid segmented control on the right.
List: table with checkbox, icon + name (label-sm), type badge, size right-aligned, owner (avatar xs + name), modified, and ghost icon actions. Selected rows get the action-subtle fill.
Grid: cards with a 16:10 preview, name, type badge and size; 4 per row, 24px gap.
Upload: a dropzone card at the top when the folder is empty or on hover of the page in real apps; never a floating "+" button.
Application pages
First run and empty states
What the app looks like before there is data: a dashboard with a setup checklist and empty widgets, an empty table with an import call to action, a no-results state and an error state.
Product previewPrimary layout
Welcome to Lumen, Maya
Let’s get your workspace ready. You can come back to this any time.
Get started with Lumen
2 of 5 steps done · about 8 minutes left
Create your workspaceDone on 11 September
Invite your team2 people joined
Import your customersCSV or connect your CRM in one click.
Connect SlackGet notified where your team already talks.
Create your first reportPick a template to see your data in minutes.
Revenue
No revenue yet
Connect your billing provider to see revenue here.
A setup checklist card replaces the metrics on the first visit: title, progress ("2 of 5 steps"), a progress bar and steps with a check circle, title, one-line description and an outline action; done steps are struck through and lose their button.
Empty widgets keep their card and header so the layout does not jump when data arrives; the body shows a small EmptyState (icon, one line, one link button).
Empty table: keep the page header, filters and the card; the body shows an EmptyState with the primary action ("Import customers") and a secondary ("Add manually").
No results: keep the search term visible in the description and offer "Clear search" (outline) before any primary action.
Error state: featured icon error outline, plain language ("We couldn’t load your customers"), a retry button and a support link; never a stack trace.
Application pages
Inbox and messaging
Three-pane inbox (sidebar, thread list, reading pane with composer), a chat conversation layout, and a notification center. The list stays visible while reading.
Three panes: navigation sidebar (296), thread list (360, own scroll, search + filter tabs at the top), reading pane (flex 1) with a header (subject, badges, actions), the messages and a sticky composer.
Thread row: avatar 40 with presence, name label-sm + time body-xs on one line, subject label-sm, one-line preview body-sm muted; unread rows show a brand dot and semibold name; the open thread has the subtle fill.
Messages are separated by 24px; each has the sender row (avatar 40, name, time) and the body in body-md with 16px paragraph spacing.
Composer: textarea (3 rows, no resize) + toolbar of ghost icon buttons on the left and Save draft / Send on the right; Send is the only primary button on the page.
Chat variant: bubbles max 70% wide, incoming on the left in bg-subtle, outgoing on the right in the action color; a date divider chip between days.
Notification center: a list grouped by day, each item with avatar, actor + action text, time, and an unread dot; "Mark all as read" as a link button in the header.
Application pages
List with detail drawer
The workhorse app page: a filterable table of records with a detail drawer that opens over the list instead of navigating away. Also as a card list and as a kanban board.
Product previewPrimary layout
Users
Manage the people in your workspace and their permissions.
Filters row above the table: view tabs (pill) on the left, search (320px) and a Filters button on the right.
The table lives in a card with its header (title + count badge + actions) and a pagination footer.
Selecting a row opens the 440px drawer on the right: header with avatar/title/status and a close button, tabs, a definition list, contextual alerts, and footer actions. The list stays visible and the selected row keeps its highlight.
Never navigate to a separate page for one record; only deep edits open a dialog or a full page.
Empty results show the EmptyState inside the table card, keeping the filters visible.
Application pages
Phone app screens
The application at 375px: top bar + content + bottom tab bar. Home with stats and a list, a searchable list with filter chips, a detail screen with a sticky action, a form, and a bottom sheet.
Shell: 56px top bar (back or logo, centered title, one icon action), scrolling content with 16px side padding, 64px bottom tab bar with 4–5 items (icon 24 + label 11px); the current tab is brand-colored.
Controls are lg (44px) on phones; primary actions sit in a sticky footer above the tab bar, full width.
Lists are edge-to-edge rows (56px) with avatar, title, one-line description and a chevron; cards only for grouped content.
Filters are horizontal scrolling chips under the search field; never a sidebar or a dropdown filter panel.
Detail screens open as pushed pages (back arrow), quick edits as bottom sheets (drawer side=bottom) with a grab handle.
Everything here is the same components as desktop with data-size="lg"; no separate mobile component set.
Application pages
Dialog and drawer flows
Overlays in context: a destructive confirmation, a form dialog, a two-step dialog and an edit drawer, each rendered over the users page with the scrim.
Product previewPrimary layout
Users
Manage the people in your workspace.
All users 240 users
Name
Status
Role
Email address
Teams
Actions
MC
Maya Chen@maya
Active
Product Manager
maya@lumen.co
DesignProduct
DC
Daniel Costa@daniel
Active
Engineering
daniel@lumen.co
DesignProduct+2
SA
Sofia Almeida@sofia
Offline
Design
sofia@lumen.co
DesignProduct
LF
Lucas Ferreira@lucas
Active
Frontend
lucas@lumen.co
DesignProduct+2
AK
Aisha Khan@aisha
Active
Backend
aisha@lumen.co
DesignProduct
NB
Noah Berg@noah
Offline
Product Design
noah@lumen.co
DesignProduct+2
Delete user
Are you sure you want to delete Daniel Costa? This action cannot be undone.
Confirmation dialogs (sm, 400px): outline featured icon (error for destructive), title heading-md, one sentence, footer with ghost Cancel + primary (danger for destructive); the destructive button names the action ("Delete user"), never "OK".
Form dialogs (md, 480px): title + description, 1–4 fields (20px apart), footer Cancel + primary; longer forms use a drawer instead.
Multi-step dialogs show a stepper or "Step 1 of 2" in the header and keep the same size across steps.
Drawers (md, 480px) slide from the right for editing a record; the list stays visible behind the scrim.
Focus is trapped, Escape closes, focus returns to the trigger; the page behind is aria-hidden.
Application pages
Onboarding wizard
First-run flow: a focused, single-column card with a step indicator, one question per step, a back/continue footer, and a finishing screen. Also as a split layout with a preview panel.
No sidebar. A minimal header (logo, product name, "Step n of 4") and a centered 560px card with 32px padding.
The stepper at the top of the card shows 3–5 steps: complete (check), current (brand), upcoming (muted); never more than 5.
One question per step: heading-lg title, body-lg supporting text, then 1–4 fields (20px apart). Continue is the only primary button; Back is ghost on the left; "Skip for now" as a link when optional.
Invite step: repeatable email rows with a role select and an "Add another" link button; the primary reads "Send invites".
Finishing screen: success featured icon (outline circle), heading-lg, body-lg, one primary "Go to dashboard".
Application pages
Roles and permissions
A roles × permissions matrix with grouped rows, a role detail with its members, and an API keys page.
Product previewPrimary layout
Roles and permissions
Control what each role can do across the workspace.
Matrix: permissions as rows grouped by area (group rows in bg-subtle, label-xs), roles as columns with the member count under the name; cells are checkboxes, the Owner column is locked (check icon, no control).
Changes are saved explicitly: a sticky footer with "Discard" and "Save changes" appears when anything changed.
Role detail: description, members list (avatar, name, email, remove) and an "Add members" primary; deleting a role requires moving its members first.
API keys: table with name, key prefix in code style, created, last used, and a Revoke ghost-danger action; new keys are shown once in a dialog with a copy button.
Application pages
Profile page
A person or entity page: cover band, avatar + identity + actions, tabs, then a two-column body (about card + activity) or a stats row. Also the public/compact variants.
Product previewPrimary layout
MC
Maya Chen Admin
Product Manager · Florianópolis, Brazil · Joined March 2024
About
Email
maya@lumen.co
Role
Product Manager
Teams
ProductDesignLeadership
Time zone
GMT−3 · 14:22 local
Bio
I’m a product manager based in Florianópolis. I like building things that make people’s work easier.
Cover band 160px in bg-action-subtle (or an image), avatar 96px (2xl) overlapping it by half, identity block right of the avatar, actions right-aligned; one primary (Edit profile / Follow).
Tabs under the header: Overview, Activity, Projects, Settings.
Body: 2:1 grid — main column (about card with description-list, projects table/list) and side column (stats, teams, contact).
Never repeat the name in the page header; the identity block is the h1.
Application pages
Settings page
Account and workspace settings: page header + tabs, then sections laid out as label-column / control-column rows separated by hairlines, with the actions at the end. Variants: profile form, team, plan & billing, notifications.
Product previewPrimary layout
Settings
Manage your account settings and preferences.
Personal info
Update your photo and personal details here.
NameShown on your profile and in mentions.
Email addressUsed for sign-in and notifications.
Your photoThis will be displayed on your profile.
MC
Click to upload or drag and dropSVG, PNG, JPG or GIF (max. 800×400px)
RoleYour title inside the workspace.
CountryWhere you are based, for tax and time zone.
Tabs under the page header select the settings area; the content is a single 1024px-max column.
Each setting is a row: left column 280px with a semibold label and a one-line hint; right column the control(s), max 512px. Rows separated by 20px padding and a hairline.
Section header (heading-sm + description + actions) at the top of each area; the save actions repeat at the bottom of long forms.
Destructive actions (delete account) sit in their own last section with a danger-outline button.
Team: section header with count badge, a table with checkbox, avatar, status, role, email and actions, then pagination.
Shared pages
Auth and error pages
Log in, sign up, forgot password, verification code and 404 — centered single-column cards (360px) or split with an image. Shared across every product.