Skip to content
  1. Keyline UI
  2. Theming
Keyline UIComponents
  • Get started
    • Installation
    • Theming
    • Modes
    • Configuration
    • Composition
  • Actions
    • Button
    • Button group
    • Split button
    • Toggle
    • Toggle group
    • Toolbar
    • Menu
    • Menubar
    • Context menu
  • Inputs
    • Field and form
    • Checkbox
    • Checkbox group
    • Radio group
    • Switch
    • Slider
    • Segmented
    • Select
    • Native select
    • Combobox
    • Multiselect
    • Number field
    • OTP field
    • Time field
    • Date picker and calendar
    • Date range
  • Data
    • Table
    • Chart
    • Stat and meter
    • Status and badge
    • Progress
    • Skeleton
    • Empty
    • Avatar
    • Kbd
    • Marker
  • Navigation
    • Tabs
    • Breadcrumb
    • Pagination
    • Nav
    • Navigation menu
    • Command menu
  • Overlays
    • Dialog
    • Alert dialog
    • Drawer
    • Sheet
    • Popover
    • Hover card
    • Tooltip
    • Toast
    • Notice
  • Layout
    • App shell
    • Sidebar
    • Panel
    • Resizable
    • Scroll area
    • Accordion
    • Collapsible
    • Carousel
    • Inspector
    • Item
    • Direction
  • Conversation
    • Message
    • Bubble
    • Message scroller
    • Attachment
    • Questionnaire
  • Blocks
    • Shipments

Theming

You set ten source tokens; Keyline derives every other colour from them, in both themes. Components pick colours by role, not by value, and so can yours: every role is a Tailwind utility.

Loading the live example…

"use client"

// A component of your own, built only from Keyline's role utilities: no colour is written down, so the same
// markup is right in a light region and a dark one, and under any palette you configure.
const STOCK = [
  { sku: "20LB-LTR", label: "20 lb letter", cases: 412, floor: 150 },
  { sku: "24LB-LGL", label: "24 lb legal", cases: 96, floor: 120 },
  { sku: "CARD-110", label: "110 lb cardstock", cases: 38, floor: 40 },
]

function ReorderCard() {
  const low = STOCK.filter((s) => s.cases < s.floor).length
  return (
    <section aria-label="Warehouse stock" className="min-w-0 flex-1 border-3 border-kl-line bg-kl-cell font-kl text-kl-ink" data-testid="role-card">
      <header className="flex items-baseline justify-between gap-3 border-b border-kl-seam px-kl-inset py-2.5">
        <h3 className="m-0 text-[15px] font-kl-strong">Warehouse stock</h3>
        <span className="font-kl-mono text-[12px] text-kl-ink-3">SCR-01</span>
      </header>
      <ul className="m-0 list-none p-0">
        {STOCK.map((s) => {
          const short = s.cases < s.floor
          return (
            <li key={s.sku} className="flex items-center gap-3 border-b border-kl-line-soft px-kl-inset py-2 last:border-b-0">
              <span className="min-w-0 flex-1 text-[14px]">{s.label}</span>
              <span className={`font-kl-mono text-[13px] tabular-nums ${short ? "text-kl-red-text" : "text-kl-ink-2"}`}>{s.cases} cases</span>
            </li>
          )
        })}
      </ul>
      <footer className="flex items-center gap-2 bg-kl-inverse px-kl-inset py-2 text-[13px] text-kl-on-inverse">
        <span className="kl-count text-[20px]">{low}</span> below the reorder floor
      </footer>
    </section>
  )
}

export function RoleCards() {
  return (
    <div className="flex w-full flex-wrap gap-kl-gutter">
      <div className="light flex min-w-[240px] flex-1 bg-kl-paper p-3">
        <ReorderCard />
      </div>
      <div className="dark flex min-w-[240px] flex-1 bg-kl-paper p-3">
        <ReorderCard />
      </div>
    </div>
  )
}

Source tokens

These are the only colour, type and cut values you configure. Keyline's defaults sit in @layer base, so any unlayered declaration of yours wins, wherever it is in the file.

TokenDefaultMeaning
--kl-workingoklch(0.9 0.17 98)The working set: selection, tokens, ranges.
--kl-commitoklch(0.48 0.2 262)Location and commitment: the current place, the primary action, focus.
--kl-attentionoklch(0.55 0.2 33)Invalid, failed, destructive, overdue.
--kl-neutral-h262The hue of paper, ink and keylines.
--kl-neutral-c1How tinted the neutrals are: 0 is grey, 1 the default, up to 3.
--kl-fontAtkinson Hyperlegible NextThe UI voice.
--kl-font-monoAtkinson Hyperlegible MonoThe Code voice: units, IDs, numbers in tables.
--kl-weight-strong700Labels, table headers and the Count.
--kl-weight-display800Titles and glyph cells.
--kl-cut0The cut accent: 0 off, 0.5 quiet, 1 full. See Modes.
/* your stylesheet, after Tailwind; unlayered, so it wins over Keyline's defaults */
:root {
  --kl-commit: oklch(0.47 0.12 222);
  --kl-neutral-h: 225;
  --kl-neutral-c: 1.4;
}

Role weights (3, 2 and 1px), square corners, the signal jobs and the motion behaviours are not tokens: they are what Keyline is. Motion timings are tokens too, covered in Modes.

Derived colours

Fills, foregrounds, text variants, focus, selection, data colours and both themes are computed from the source tokens. They are recomputed only on :root, .light, .dark and elements with data-kl-config. Set a source token on one of those. On a plain element, a control there keeps the old derived colour while a popup opened from it takes the new one.

To change the palette of one region, use a configuration scope: data-kl-config by hand, or KeylineConfigScope (see Configuration).

Brand colours

Commit and attention fills get white or dark text by lightness; the crossover is OKLCH L 0.575. A fill picked by hand between L 0.5 and 0.65 can fall below 4.5:1 contrast with either. Keep those two fills outside that band, or let roleColor pick the lightness for a hue, as the preset palettes do (see Configuration).

Keep the three signal hues apart. Commit and attention closer than 40° make a primary action read as a warning; the working set within 30° of either merges two signals.

Colour roles

When you build your own component, pick the role, not a colour. A divider between cells of one frame is seam; a cursor or checked fill is inverse with on-inverse text.

RoleUtilityFor
paper cell raisedbg-kl-cellThe page; a panel or control; an overlay. An overlay sets --kl-cell to raised, so everything inside lifts with it.
tint presshover:bg-kl-tintHover and press feedback.
ink ink-2 ink-3text-kl-ink-2Primary, secondary and supporting text.
line seam line-softborder-kl-seamFrames and control edges; the 1px divider between parts of one frame; decorative row rules.
inverse on-inversebg-kl-inverseThe ink painted as a fill (a cursor, a checked box) and the text on it.
yellow on-yellow yellow-rowbg-kl-yellowThe working signal, the text on it, and a selected row.
cobalt redtext-kl-cobalt-textThe commit and attention signals, each with on-, -hi, -lo and -text variants (bg-kl-red-lo, text-kl-on-cobalt).
focusoutline-kl-focusFocus indicators: the commit colour's text variant.
dis dis-line scrimtext-kl-disDisabled text and edges; the scrim behind a modal.
data-1 data-2 data-3fill-kl-data-1Chart and meter series by role: the focal series, the comparison, the rest. data-mark marks a value, data-alert a breach.

Tailwind utilities

Every role is a Tailwind colour of the same name prefixed kl-, so it works with any colour utility (bg-, text-, border-, fill-, outline-) and your editor completes it. The utilities follow .dark and configuration scopes like the tokens do, and leave shadcn's bg-primary or border-border alone. Arbitrary values work too: text-(--kl-ink-2).

<div className="border-3 border-kl-line bg-kl-cell text-kl-ink">
  <h3 className="font-kl-strong">Warehouse stock</h3>
  <p className="text-kl-ink-2">412 cases</p>
  <footer className="bg-kl-inverse px-kl-inset text-kl-on-inverse">…</footer>
</div>
UtilitySetsFor
font-klfont-familyThe UI voice.
font-kl-monofont-familyThe Code voice.
font-kl-strongfont-weightThe strong weight, --kl-weight-strong.
font-kl-displayfont-weightThe display weight, --kl-weight-display.
kl-inset kl-gutterspacingThe density's inner padding and gap, with any spacing utility: px-kl-inset, gap-kl-gutter.
kl-code kl-countclassThe two public classes: the Code voice for units and IDs, and the Count voice for big numbers.

Every other kl- class is internal and can change between versions. If a composition seems to need one, that is a gap to report (see Composition).

Fonts

Keyline has two voices, UI and Code, set by --kl-font and --kl-font-mono. Atkinson Hyperlegible is the default; IBM Plex and the system fonts are tested alternatives.

FontConfig valueInstalls
Atkinson Hyperlegible Next and MonodefaultComes with @keyline-ui/keyline: two npm packages imported from your stylesheet, bundled with your app.
IBM Plex Sans and Monoplex@fontsource-variable/ibm-plex-sans and @fontsource/ibm-plex-mono. Plex Sans stops at weight 700, so the display weight is held at 700 too.
System UIsystemThe platform's own sans and mono. Nothing to install.

IBM Plex

Install the packages, then import them and point the tokens at them:

npm install @fontsource-variable/ibm-plex-sans @fontsource/ibm-plex-mono
@import "@fontsource-variable/ibm-plex-sans";
@import "@fontsource/ibm-plex-mono/400.css";
@import "@fontsource/ibm-plex-mono/500.css";

:root {
  --kl-font: "IBM Plex Sans Variable", ui-sans-serif, system-ui, sans-serif;
  --kl-font-mono: "IBM Plex Mono", ui-monospace, monospace;
  --kl-weight-display: 700;
}

System fonts

:root {
  --kl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --kl-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

configToCss writes these lines for a configuration (see Configuration). The Atkinson packages stay imported after a switch; remove their @import lines yourself if you no longer want them.

Beside shadcn

Keyline's CSS writes only its own names: --kl-* tokens, kl- classes and data-kl-* attributes. It sets no body styles, no shadcn tokens, no --radius, no --font-sans and no global focus ring, so stock shadcn components on the same page look as they did. Keyline's files go to components/keyline/, so its Button and shadcn's Button can both be imported.

Each outermost Keyline element sets Keyline's font and ink itself, so a component keeps its voice inside an app that uses another font.

The whole app in Keyline

To give stock shadcn components Keyline's look too, add the opt-in theme:

npx shadcn@latest add @keyline-ui/keyline-shadcn

It maps shadcn's semantic colour tokens, sidebar tokens included, to Keyline roles in :root, .light, .dark and configuration scopes. It also sets --radius: 0px, points --font-sans and --font-mono at Keyline's fonts, styles body, adds a :focus-visible ring and sets color-scheme per theme. --popover becomes raised, --border seam, --input line and --ring focus. --chart-* is left to you: Keyline's data colours are roles, not a series.

Because it restyles body and shadcn's tokens, add it only when the whole app should be Keyline. Removing it means deleting those rules from your stylesheet; the CLI does not remove CSS.

Sheet 3 of 73PreviousInstallationNextModes
On this page