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

Composition

Keyline components join on shared keylines: a frame holds cells, and the seam between two cells is drawn once. A few rules keep that true when you put components together.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldFrame, FieldInput, FieldLabel } from "@/components/keyline/ui/field"
import { Panel, PanelBody, PanelFooter, PanelHeader } from "@/components/keyline/ui/panel"
import { Segmented, SegmentedItem } from "@/components/keyline/ui/segmented"

// The same footer twice. Putting a Button inside the Panel's footer changes nothing by itself: as a control it
// keeps its own 2px frame and doubles the footer's edges. Declared as a cell, it fills its cell and the footer
// draws the one seam between the parts.
export function DeclaredCells() {
  const [layout, setLayout] = useState<"cell" | "control">("cell")
  const [sent, setSent] = useState(false)

  return (
    <div className="grid w-full max-w-[520px] gap-4">
      <Segmented size="compact" aria-label="Footer buttons" value={layout} onValueChange={(v: string) => setLayout(v as "cell" | "control")} className="justify-self-start">
        <SegmentedItem value="cell">layout=&quot;cell&quot;</SegmentedItem>
        <SegmentedItem value="control">layout=&quot;control&quot;</SegmentedItem>
      </Segmented>
      <Panel>
        <PanelHeader code="PO-118" title="Reorder copy paper" description="Stamford asked for the order to arrive before the audit." />
        <PanelBody>
          <Field name="cases">
            <FieldLabel>Cases</FieldLabel>
            <FieldFrame>
              <FieldInput type="number" inputMode="numeric" defaultValue="40" align="end" />
              <Button layout="cell">Add 10</Button>
            </FieldFrame>
          </Field>
        </PanelBody>
        <PanelFooter data-testid="footer">
          <span className="flex flex-1 items-center px-3 text-[13px] text-kl-ink-2" role="status">
            {sent ? "Sent to Jan." : "Draft"}
          </span>
          <Button layout={layout}>Save draft</Button>
          <Button layout={layout} variant="primary" disabled={sent} onClick={() => setSent(true)}>
            Send order
          </Button>
        </PanelFooter>
      </Panel>
    </div>
  )
}

Terms used in these docs

The component pages use a few words in a narrow sense. Each describes something you can see on the screen.

  • Frame. The border around a component or region. Its weight says what it is: 3px for a region such as a panel, 2px for a standalone control.
  • Cell. One part of a frame: a button in a footer, a tab, a table cell. State is shown inside a cell, so the layout around it doesn't move.
  • Seam or keyline. The 1px line where two cells meet. It is drawn once, by one of them, so neighbours share it.
  • Docked. A menu, list or calendar that opens against its trigger's edge and shares it, so the two read as one shape.
  • Flood and band. A cell's own state fills the whole cell (a flood); a choice moving within a group slides one band to it.
  • Ink, cobalt, yellow, red. The four state colours. Ink is the current choice; cobalt the current place and the primary action; yellow the working set you are about to act on, such as selected rows; red what needs attention: invalid, failed or destructive. Each also has a shape, glyph or position, so nothing depends on colour alone.
  • Code voice. The monospace face, used for codes, keys, figures and timeline steps.
  • Lamp. A small square that is hollow when off and solid when on, for independent on/off state.
  • The cut. An optional bevel on a frame's top-start corner. It is off by default; see Modes.

Controls and cells

Layout is declared, never inferred.

  • A control takes layout="control", the default: its own 2px frame and, when the cut is on, the cut.
  • With layout="cell", the control is a cell of the frame it sits in: no border, no cut, no background, and it fills the cell.
  • Putting a control inside a frame changes nothing by itself. Declare layout="cell".
<PanelFooter>
  <span className="flex-1 px-3">Draft</span>
  <Button layout="cell">Save draft</Button>
  <Button layout="cell" variant="primary">Send order</Button>
</PanelFooter>

One owner per edge

Rows of cells (panel and dialog footers, bulk bars, pagers, inspector actions) draw the dividers between their cells. Don't add borders to cell controls: a second line on the same edge reads as a thicker one.

Sizing

Sizing is yours; the narrow layout is the component's.

  • Give parts widths with your own classes. They describe the wide layout.
  • Components that rearrange when narrow measure themselves, not the viewport. A table frame narrower than 560px stacks its toolbar and bulk bar; a Form with orientation="responsive" goes horizontal from 560px of its own width.
  • Size a scroll region's root yourself (a height or max-height on Scroll Area). Don't wrap Select, Multiselect, Menu or Command lists in one: they keep their own native scroller.

Overlays

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 on --kl-z-toast. Each overlay keeps the configuration of the place it is written (see Configuration).

The public API

Compose with props, Tailwind utilities and the role utilities. The public classes are kl-code (the Code voice: units, IDs) and kl-count (the Count voice: big numbers). Every other kl- class is internal and may change. If a composition needs one, that is a gap in Keyline: report it rather than depending on the class.

Shared helpers

The lib/ items are installed with the components that need them. Only some of them are contracts you can build on:

ItemStatusUse
keyline-configSupportedKeylineConfigScope and the configuration helpers. See Configuration.
keyline-scopeSupporteduseKeylineScope and scopeProps, for an overlay of your own. See Configuration.
selectionSupportedSelection by id that survives filtering and paging, with undo by snapshot. See below.
collectionThrough TableThe sort cycle and comparator. Import them from table, which re-exports them (see Table).
calendarThrough Date rangeformatRange and countAvailable, re-exported by Date range.
keyline-motion options scroll-edgesInternalInstalled as dependencies of the components that use them. Their exports may change between versions; don't build on them.

Selection

Records are selected by id, so a selection survives filtering, sorting and paging. The scope tells you how many selected records the current filters hide, because a bulk action still acts on them; say so in the bulk bar. Undo restores each record's own previous value and puts removed records back where they were. The Shipments block uses all of it.

npx shadcn@latest add @keyline-ui/selection
import { useMemo } from "react"
import { useSelection, patchWithUndo } from "@/components/keyline/lib/selection"

// matching: every row the filters let through; page: the rows on screen now. Keep both stable.
const matching = useMemo(() => filtered.map((r) => r.id), [filtered])
const page = useMemo(() => shown.map((r) => r.id), [shown])
const { selected, scope, toggle, setPage, selectAllMatching, keepVisibleOnly, clear } = useSelection(matching, page)

// scope.count selected in all; scope.hidden of them are filtered out but still acted on
const reassign = (carrier: string) => {
  const { next, undo, count } = patchWithUndo(rows, selected, (r) => ({ ...r, carrier }))
  setRows(next)
  notify({ title: `${count} reassigned`, action: { label: "Undo", onClick: () => setRows(undo) } })   // notify = useToast()
}

scope holds count, visible, hidden, page ("all", "some" or "none", for the header checkbox) and allMatching. removeWithUndo works like patchWithUndo and returns { next, undo, removed }. The pure functions (toggle, setMany, restrictTo, scopeOf) are exported too, for state kept outside React.

Known limitations

What Keyline does not do yet, and what to do instead. Most come from the Base UI primitive underneath.

WhereCauseWhat to do
Hover cardBase UI Preview CardIts content is not reachable by keyboard or screen reader. Use it only to preview what the linked page already holds; never put actions in it.
Context menuBy designA shortcut, not the only path. Put every action somewhere visible too.
ToolbarBase UI ToolbarHome and End do nothing; the arrow keys move and wrap.
ToastBase UI ToastA failed toast's action sits in an aria-hidden root until the toast takes focus, because Base UI announces high-priority toasts through its own live region. Keyboard users reach it with F6.
ResizableWCAG 2.5.7Dragging needs a pointer alternative. Double-click resets the layout; where a panel can be hidden, add a button that calls panelRef.current.collapse() or .expand(). Name each handle after the panel it resizes.
FormsUnevenDatePicker takes name but no required, invalid, error or validate. DateRangePicker and Multiselect take no name. RadioGroup has no legend, description or error props. Multiselect needs loadOptions, even for a fixed list.
Buttonsignal="done"Stays until you set it back to "idle".
The cutOutside ChromiumDrawn with clip-path; a focus outline stops at the diagonal instead of following it.
Text-entry focusWCAG 2.4.13Fields, select and picker triggers meet WCAG 2.2 AA, not AAA 2.4.13, on standalone controls. Set --kl-edge-band: 2px if you need it.
Sheet 6 of 73PreviousConfigurationNextButton
On this page