Skip to content
  1. Keyline UI
  2. Configuration
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

Configuration

One plain object describes everything you can configure. Apply it as CSS for the whole app, or as a scope for one region: a tenant's portal, an embedded widget, a preview. Overlays opened inside a scope keep its configuration.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { CheckboxField } from "@/components/keyline/ui/checkbox"
import { Badge } from "@/components/keyline/ui/status"
import { Segmented, SegmentedItem } from "@/components/keyline/ui/segmented"
import { Select, type SelectOption } from "@/components/keyline/ui/select"
import { DEFAULT_CONFIG, KeylineConfigScope, PALETTES, configToCss, type KeylineConfig, type PaletteId } from "@/components/keyline/lib/keyline-config"

const BRANCHES: SelectOption[] = [
  { value: "scranton", label: "Scranton" },
  { value: "stamford", label: "Stamford" },
  { value: "nashua", label: "Nashua" },
]

// One branch's portal in its own palette. theme "system" follows the page, so only the palette changes here.
// configToCss prints the same configuration as CSS: what you would paste or install to make it permanent.
export function BranchTheme() {
  const [palette, setPalette] = useState<PaletteId>("harbor")
  const [branch, setBranch] = useState<string | null>("stamford")
  const config: KeylineConfig = { ...DEFAULT_CONFIG, theme: "system", palette: { ...PALETTES[palette] } }

  return (
    <div className="grid w-full gap-4">
      <Segmented size="compact" aria-label="Palette" value={palette} onValueChange={(v: string) => setPalette(v as PaletteId)} className="justify-self-start">
        {(Object.keys(PALETTES) as PaletteId[]).map((id) => (
          <SegmentedItem key={id} value={id}>
            {PALETTES[id].label}
          </SegmentedItem>
        ))}
      </Segmented>

      <KeylineConfigScope config={config} name="branch" data-testid="config-scope" className="grid gap-kl-gutter border-3 border-kl-line bg-kl-paper p-kl-gutter text-kl-ink">
        <div className="flex flex-wrap items-center gap-3">
          <Badge tone="signal">NEW</Badge>
          <Badge tone="attention">OVERDUE</Badge>
          <span className="text-[14px] text-kl-ink-2">Branch portal</span>
        </div>
        <Select label="Branch" options={BRANCHES} value={branch} onValueChange={setBranch} />
        <CheckboxField label="Send the weekly report to corporate" defaultChecked />
        <div className="flex gap-2">
          <Button variant="primary">Approve orders</Button>
          <Button>Later</Button>
        </div>
      </KeylineConfigScope>

      <pre data-testid="config-css" className="m-0 overflow-x-auto border-2 border-kl-line-soft bg-kl-cell p-3 font-kl-mono text-[12px]/[1.6] text-kl-ink-2">
        {configToCss(config, { scope: "branch" })}
      </pre>
    </div>
  )
}

The configuration

The palette, font, weight and cut become the ten source tokens (see Theming); density, theme and motion become the mode attributes (see Modes). The helpers ship in the keyline-config item:

npx shadcn@latest add @keyline-ui/keyline-config
type KeylineConfig = {
  palette: {
    working: string      // oklch(), the working set
    commit: string       // oklch(), location and commitment
    attention: string    // oklch(), invalid, failed, destructive
    neutralHue: number   // 0–360
    neutralTint: number  // 0–3
  }
  font: "atkinson" | "plex" | "system"
  strong: 600 | 700 | 800
  cut: "full" | "quiet" | "off"
  density: "compact" | "default" | "comfortable"
  theme: "light" | "dark" | "system"
  motion: "full" | "reduce"
}

The whole app

For a configuration that never changes at runtime, write the tokens as CSS and set the mode attributes on <html>. Generate the CSS with configToCss, or write it by hand: it is plain custom properties, unlayered, after Tailwind.

import { DEFAULT_CONFIG, configAttributes, configToCss } from "@/components/keyline/lib/keyline-config"

const config = { ...DEFAULT_CONFIG, font: "plex", cut: "quiet" } as const

configToCss(config)                          // :root { …the tokens that differ from the defaults }
configToCss(config, { scope: "billing" })    // [data-kl-config="billing"] { … }
configAttributes(config)                     // { className, "data-kl-density", "data-kl-motion" } for <html>

One region

KeylineConfigScope renders a div with data-kl-config, the mode attributes and the tokens as inline style. Everything inside it, and every overlay opened from inside it, takes the configuration.

import { DEFAULT_CONFIG, KeylineConfigScope, PALETTES } from "@/components/keyline/lib/keyline-config"

const branch = { ...DEFAULT_CONFIG, theme: "system", palette: { ...PALETTES.harbor } } as const

export function BranchPortal({ children }: { children: React.ReactNode }) {
  return (
    <KeylineConfigScope config={branch} name="branch">
      {children}
    </KeylineConfigScope>
  )
}

Without the component, the same scope is an attribute and a rule. Derived colours are recomputed on any element with data-kl-config, so the palette tokens have to sit on one:

/* your stylesheet: the tokens for one scope */
[data-kl-config="billing"] {
  --kl-commit: oklch(0.47 0.12 222);
  --kl-neutral-h: 225;
}
<section data-kl-config="billing" data-kl-density="compact">…</section>

KeylineConfigScope

PropTypeDefaultDescription
configKeylineConfigDefault —The whole configuration. Start from DEFAULT_CONFIG and change what you need.
namestringDefault ""Written to data-kl-config. Name it to also target the scope from your own CSS.
...propsComponentProps<"div">Default —Passed to the div. Your className and style are merged with the scope's.

Theme "system" sets no class, so the scope follows the page. A scope's density is always written, so a default-density scope inside a compact page is default.

Helpers

Everything below is exported from @/components/keyline/lib/keyline-config. Use them to build a theme from settings your users choose: a tenant's brand colour, a density preference, a saved configuration.

ExportShapeWhat it does
KeylineConfigScopecomponentA div that applies a configuration to everything inside it, overlays included.
DEFAULT_CONFIGKeylineConfigKeyline's defaults. Its theme is "light": set "system" to follow the page.
PALETTESobjectFour presets checked for signal contrast in both themes: signal (the default), harbor, ember, ledger.
configTokens(config) => RecordEvery token the configuration sets, defaults included.
changedTokens(config) => RecordOnly the tokens that differ from the defaults.
configAttributes(config) => propsThe class and data-kl-* attributes for the theme, density and motion. Theme "system" sets no class.
configToCss(config, { scope?, all? }) => stringUnlayered CSS on :root or [data-kl-config="scope"], with the font's @import lines.
normalizeConfig(unknown) => KeylineConfigFills missing or invalid fields from the defaults, so a stored configuration never breaks.
paletteWarnings(palette) => string[]Warns when two signal hues are too close to tell apart.
roleColor(role, hue) => stringA signal colour for a hue, with its lightness fitted so on-colour text holds contrast in both themes.

Portals

Menus, selects, popovers, date panels, dialogs, sheets, tooltips and toasts render in a portal under <body>. Each leaves a hidden marker where it is written and, when it opens, copies that place's resolved configuration onto its own root: the ten source tokens, .dark or .light, density, motion and dir. It re-reads while open, so a theme switch reaches an open menu. There is no provider.

All overlays share one level (--kl-z-overlay) and stack in the order they open, so a select inside a dialog opens above it. Floating toasts sit above them on --kl-z-toast.

Your own overlay

An overlay you build yourself carries the configuration the same way, with useKeylineScope and scopeProps from the keyline-scope item. Render the marker where the component is written, the probe inside the portal, and spread scopeProps(scope) on the portaled root:

npx shadcn@latest add @keyline-ui/keyline-scope
"use client"

import { Popover } from "@base-ui/react/popover"
import { scopeProps, useKeylineScope } from "@/components/keyline/lib/keyline-scope"

// the popup half of a Base UI Popover: render it inside <Popover.Root> beside your <Popover.Trigger>
export function NotePopupContent({ children }: { children: React.ReactNode }) {
  // open = false: the probe inside the portal reads the scope only while the popup is mounted
  const [marker, scope, probe] = useKeylineScope(false)
  return (
    <>
      {marker}
      <Popover.Portal>
        {probe}
        <Popover.Positioner {...scopeProps(scope)}>
          <Popover.Popup className="border-2 border-kl-line bg-kl-raised text-kl-ink">{children}</Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </>
  )
}

useKeylineScope(open, container) returns [marker, scope, probe]. With open true it reads after each render of its owner; with false the probe reads only while the portal is mounted. Pass container when the portal is not under <body>. Keyline's own overlays already do this; use it only for one of yours.

Sheet 5 of 73PreviousModesNextComposition
On this page