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

Modes

Theme, density, the cut, motion and direction are classes or attributes. Set one on <html> for the whole app, or on any element for the region inside it. Menus and popups opened from that region follow it.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { DirectionProvider } from "@/components/keyline/ui/direction"
import { Field, FieldFrame, FieldInput, FieldLabel } from "@/components/keyline/ui/field"
import { Segmented, SegmentedItem } from "@/components/keyline/ui/segmented"
import { Select, type SelectOption } from "@/components/keyline/ui/select"

const REPS: SelectOption[] = [
  { value: "dwight", label: "Dwight Schrute" },
  { value: "jim", label: "Jim Halpert" },
  { value: "phyllis", label: "Phyllis Vance" },
]

type Theme = "page" | "light" | "dark"
type Density = "compact" | "default" | "comfortable"
type Cut = "off" | "quiet" | "on"

// Every mode is an attribute or a class on the region, so it applies to this subtree only. The Select's list
// opens in a portal under <body> and still takes the region's theme, density, cut and direction.
export function ModesRegion() {
  const [theme, setTheme] = useState<Theme>("dark")
  const [density, setDensity] = useState<Density>("compact")
  const [cut, setCut] = useState<Cut>("off")
  const [reduce, setReduce] = useState(false)
  const [dir, setDir] = useState<"ltr" | "rtl">("ltr")
  const [rep, setRep] = useState<string | null>("jim")

  return (
    <div className="grid w-full gap-4">
      <div className="flex flex-wrap gap-x-4 gap-y-2">
        <Segmented size="compact" aria-label="Theme" value={theme} onValueChange={(v: string) => setTheme(v as Theme)}>
          <SegmentedItem value="page">Page</SegmentedItem>
          <SegmentedItem value="light">Light</SegmentedItem>
          <SegmentedItem value="dark">Dark</SegmentedItem>
        </Segmented>
        <Segmented size="compact" aria-label="Density" value={density} onValueChange={(v: string) => setDensity(v as Density)}>
          <SegmentedItem value="compact">Compact</SegmentedItem>
          <SegmentedItem value="default">Default</SegmentedItem>
          <SegmentedItem value="comfortable">Comfortable</SegmentedItem>
        </Segmented>
        <Segmented size="compact" aria-label="Cut" value={cut} onValueChange={(v: string) => setCut(v as Cut)}>
          <SegmentedItem value="off">Cut off</SegmentedItem>
          <SegmentedItem value="quiet">Quiet</SegmentedItem>
          <SegmentedItem value="on">On</SegmentedItem>
        </Segmented>
        <Segmented size="compact" aria-label="Motion" value={reduce ? "reduce" : "full"} onValueChange={(v: string) => setReduce(v === "reduce")}>
          <SegmentedItem value="full">Full motion</SegmentedItem>
          <SegmentedItem value="reduce">Reduced</SegmentedItem>
        </Segmented>
        <Segmented size="compact" aria-label="Direction" value={dir} onValueChange={(v: string) => setDir(v as "ltr" | "rtl")}>
          <SegmentedItem value="ltr">LTR</SegmentedItem>
          <SegmentedItem value="rtl">RTL</SegmentedItem>
        </Segmented>
      </div>

      <DirectionProvider direction={dir}>
        <div
          data-testid="mode-region"
          className={`${theme === "page" ? "" : theme} grid gap-kl-gutter border-3 border-kl-line bg-kl-paper p-kl-gutter text-kl-ink`}
          data-kl-density={density}
          data-kl-cut={cut}
          data-kl-motion={reduce ? "reduce" : undefined}
        >
          <div className="grid gap-kl-gutter sm:grid-cols-2">
            <Select label="Assign the reorder to" options={REPS} value={rep} onValueChange={setRep} />
            <Field name="cases">
              <FieldLabel>Cases</FieldLabel>
              <FieldFrame>
                <FieldInput type="number" inputMode="numeric" defaultValue="40" align="end" />
              </FieldFrame>
            </Field>
          </div>
          <div className="flex gap-2">
            <Button variant="primary">Place reorder</Button>
            <Button>Save draft</Button>
          </div>
        </div>
      </DirectionProvider>
    </div>
  )
}

Overview

To setUseWhere
Theme.dark · .lightAny subtree, including a light region inside a dark page.
Densitydata-kl-density="compact" | "default" | "comfortable"Any subtree.
Cutdata-kl-cut="off" | "quiet" | "on", or --kl-cut:root or any subtree.
Reduced motiondata-kl-motion="reduce"Any subtree. prefers-reduced-motion is honoured anyway.
Directiondir="rtl", or DirectionProviderAny subtree.

Modes change how Keyline is tuned, not what it is: border weights, square corners and the signal colours' jobs stay the same in every mode.

Dark mode

Keyline follows a .dark or .light class on an ancestor. It does not read prefers-color-scheme itself. shadcn's dark-mode setups (next-themes with attribute="class", or its Vite theme provider) already set that class, so nothing else is needed.

<html className="dark">          {/* next-themes with attribute="class" sets this for you */}
  …
  <section className="light">…</section>   {/* a light region inside the dark page */}
</html>

Dark is tuned, not mirrored. Surfaces lift off black, primary text stops short of white, edges step down in tone by role, and the inverse fill is a light grey rather than white.

Density

Density sets control height, row height, inset, gutter, base type size and the cut's sizes. It never changes border weights. The figures below are control height · row height · inset · gutter · type size.

DensitySizesFor
compact28 · 28 · 8 · 12 · 13pxDense tools: tables with many rows, inspectors, admin screens.
default36 · 40 · 12 · 12 · 14pxWhat you get without the attribute.
comfortable40 · 48 · 16 · 16 · 15pxTouch-first screens and forms read at a distance.

Each density states all of its sizes, so a compact region inside a comfortable page does not inherit the page's 16px gutter.

Cut

The cut is an optional diagonal accent on frames and controls, off by default. data-kl-cut sets it for a region, or set --kl-cut directly: 0 off, 0.5 quiet, 1 full.

Outside Chromium the cut is drawn with clip-path, and a focus outline stops at the diagonal instead of following it.

Right to left

Keyline's CSS and its components that read layout from the DOM (Toolbar, Slider, Scroll Area, Carousel) follow the DOM's dir. Base UI decides which side a tooltip, popover, hover card or menu opens on, and which way its arrow keys go, from its own provider. DirectionProvider sets both and adds no box:

npx shadcn@latest add @keyline-ui/direction
import { DirectionProvider } from "@/components/keyline/ui/direction"

<DirectionProvider direction="rtl" lang="ar">
  …
</DirectionProvider>

Reduced motion

data-kl-motion="reduce" on a region, or prefers-reduced-motion: reduce in the reader's system, sets every motion duration to zero. State still changes, at once: a band jumps instead of stretching, a panel opens without unfolding.

Timings are tokens too (--kl-t-flood, --kl-t-unfold, --kl-stretch-lead, …), and two set a behaviour you can turn off for one region:

/* turn off the press tug and the flood seed in one region */
.my-table-region {
  --kl-stretch-tug: 0;
  --kl-flood-seed: 0px;
}

A mode for one region

Every mode composes on one element. Overlays opened from inside the region (select lists, menus, popovers, dialogs, tooltips, toasts) render under <body> but carry the region's theme, density, motion, direction and tokens with them. There is no provider to add.

<div className="dark" data-kl-density="compact" data-kl-cut="quiet" data-kl-motion="reduce">
  …
</div>

To change the palette or fonts of a region as well, make it a configuration scope (see Configuration).

Sheet 4 of 73PreviousThemingNextConfiguration
On this page