Skip to content

Forms

Every control, every state — the base layer styles the natives with zero classes, and a handful of thin classes handle labels, sizes, and layout.

Text inputs

Every text-like input is styled by the base layer with no classes at all — text, email, password, search, number, tel, and url all come out identical: the same surface, border, radius, and soft accent focus ring. .k-field just spaces a label/input group, and .k-field__hint is small helper text under the input. Connect the hint with aria-describedby so screen readers read it with the field.

We only use this to send the invoice.

At least 12 characters.

Markup
<div class="k-field">
  <label for="email">Email</label>
  <input id="email" type="email" aria-describedby="email-hint">
  <p class="k-field__hint" id="email-hint">We only use this to send the invoice.</p>
</div>

<!-- every text-like type is styled identically, no classes:
     text · email · password · search · number · tel · url -->

Textarea & select

Also base-layer, also classless. textarea gets the field treatment plus resize: vertical and a sensible min-height — users can make it taller, never break the layout sideways. select is de-natived (appearance: none) and drawn with keel's own chevron, so it matches the other fields in every browser; the dropdown list itself is still the OS's. select[multiple] keeps its list open and gets padded, rounded option rows.

Hold Ctrl (or Cmd) to pick more than one.

Markup
<div class="k-field">
  <label for="message">Message</label>
  <textarea id="message"></textarea>
</div>

<div class="k-field">
  <label for="plan">Plan</label>
  <select id="plan">
    <option>Starter</option>
    <option selected>Studio</option>
  </select>
</div>

<select multiple size="4">
  <option selected>Email</option>
  <option>SMS</option>
</select>

Token multi-select

Native select[multiple] works, but the pattern real apps reach for is a field of removable chips with a text entry that grows to fill the row — .k-tokens is that field, a .k-token is each chip, and .k-token__remove is its × button. The whole field lights up on :focus-within, so the inner input reads as one control with the chips. This is chrome only: keel draws the field, your JS adds and removes tokens and opens the picker. It pairs naturally with a checkable .k-menu — a dropdown of aria-checked options — as the "add" surface. Give each remove button an aria-label, and mirror the current tokens into a real hidden field so the form submits.

Markup
<label class="k-tokens">
  <span class="k-token">Design
    <button type="button" class="k-token__remove" aria-label="Remove Design">×</button>
  </span>
  <span class="k-token">Research
    <button type="button" class="k-token__remove" aria-label="Remove Research">×</button>
  </span>
  <input type="text" placeholder="Add topic…">
</label>

<!-- token color is a knob: --k-token-bg / --k-token-fg -->

Value + unit

The control every inspector needs: a number with a unit picker beside it, drawn as one field. .k-unit is the wrapper — it holds a borderless input that fills the row and a trailing unit, set off by a hairline, and the whole thing lights up on :focus-within. The unit can be a real select (native list, no JS), a .k-unit__btn that opens a .k-menu when you need custom units, or a static .k-unit__affix for a fixed $, %, or px — put the affix first for a leading symbol, last for a trailing one. It drops straight into a .k-props__row, which is where a design tool's inspector uses it.

$
%
Markup
<!-- value + unit dropdown -->
<div class="k-unit">
  <input type="number" value="12" aria-label="Size">
  <select aria-label="Unit">
    <option>px</option><option>rem</option><option>%</option>
  </select>
</div>

<!-- leading affix -->
<div class="k-unit">
  <span class="k-unit__affix">$</span>
  <input type="number" value="40.00">
</div>

<!-- trailing affix -->
<div class="k-unit">
  <input type="number" value="100">
  <span class="k-unit__affix">%</span>
</div>

<!-- unit as a button that opens a .k-menu (your JS) -->
<div class="k-unit">
  <input type="number" value="1.5">
  <button type="button" class="k-unit__btn">rem
    <span class="k-icon k-icon--chevron-down"></span>
  </button>
</div>

Rating

A row of stars for showing a score. .k-rating is the row; each star is a filled-star k-icon--star-fill, and the earned ones get .is-on — gold from the --k-rating-on knob (default --k-warn) — while the rest sit in --k-rating-off (default --k-border-strong). .k-rating__score is a quiet numeric label after the stars. This is the display look: give the wrapper role="img" and an aria-label like "4 out of 5" so the whole thing reads as one value. For an input rating you'd wrap radio buttons instead; keel draws the read-only face.

4.0
Markup
<span class="k-rating" role="img" aria-label="4 out of 5">
  <span class="k-icon k-icon--star-fill is-on" aria-hidden="true"></span>
  <span class="k-icon k-icon--star-fill is-on" aria-hidden="true"></span>
  <span class="k-icon k-icon--star-fill is-on" aria-hidden="true"></span>
  <span class="k-icon k-icon--star-fill is-on" aria-hidden="true"></span>
  <span class="k-icon k-icon--star-fill" aria-hidden="true"></span>
  <span class="k-rating__score">4.0</span>
</span>

<!-- colors are knobs: --k-rating-on / --k-rating-off -->
<!-- for an input rating, wrap radio buttons instead -->

Combobox

A searchable single-select: an input you type into above a list you filter as you go. .k-combobox is the wrapper, and .k-combobox__list is the floating listbox below it — it's also a .k-menu, so the option rows come styled. Mark the active option with aria-selected="true" and it highlights. This is chrome only: keel draws the field and the list; your JS filters the options as the user types and toggles hidden on the list to open and close it. The demo below leaves the list open so you can see it.

Markup
<div class="k-combobox">
  <input type="text" placeholder="Search people…" aria-label="Assignee">
  <ul class="k-combobox__list k-menu">
    <li><button type="button">Ada Lovelace</button></li>
    <li><button type="button" aria-selected="true">Grace Hopper</button></li>
    <li><button type="button">Katherine Johnson</button></li>
  </ul>
</div>

<!-- your JS filters the options and toggles [hidden] on the list -->

Number stepper

A number input with joined − / + buttons, drawn as one control. .k-stepper is the wrapper — it holds a button, the native input type="number" (its own spinners hidden), and a second button, all set off by hairlines, and the whole thing lights up on :focus-within like the other joined fields. The buttons are chrome: your JS increments and decrements the value. Label the input, and give each button an aria-label so the control is clear to screen readers.

Markup
<div class="k-stepper">
  <button type="button" aria-label="Decrease">−</button>
  <input type="number" value="1" aria-label="Quantity">
  <button type="button" aria-label="Increase">+</button>
</div>

<!-- the buttons are chrome; your JS does the +/− -->

Checkboxes & radios

keel keeps the native controls and paints them with accent-color — the check and the dot take the accent token, and everything else (keyboard, forms, screen readers) is untouched browser behavior. Group related options in a fieldset with a legend; that's the accessible name for the whole group. Base labels are block, so for a control-plus-text row give the label an inline-flex — the same pattern Switch uses.

Notify me about
Billing cycle
Markup
<fieldset>
  <legend>Notify me about</legend>
  <label style="display: flex; align-items: center; gap: var(--k-space-2)">
    <input type="checkbox" checked> <span>Deploys</span>
  </label>
  …
</fieldset>

<fieldset>
  <legend>Billing cycle</legend>
  <label style="display: flex; align-items: center; gap: var(--k-space-2)">
    <input type="radio" name="cycle" checked> <span>Monthly</span>
  </label>
  …
</fieldset>

Switch

.k-switch restyles a real input type="checkbox" into a toggle — it must be a real checkbox, wrapped in a label, so keyboard, forms, and screen readers all work untouched. Always pair it with visible text; a toggle with no label is a guess.

Two size modifiers scale the track and knob together, on the label: .k-switch--small and .k-switch--large; the default needs no class. .k-switch--ok turns the on state green (the --k-ok token) instead of accent — for a toggle whose "on" reads as active or healthy. It's just the --k-switch-on knob, so you can set that property inline to any color you need.

Markup
<label class="k-switch" style="display: flex; align-items: center; gap: var(--k-space-3)">
  <input type="checkbox" checked>
  <span>Email me when a build finishes</span>
</label>

<label class="k-switch k-switch--small">…</label>
<label class="k-switch">…</label>               <!-- default -->
<label class="k-switch k-switch--large">…</label>

<!-- green when on; or set the knob to any color -->
<label class="k-switch k-switch--ok">…</label>
<label class="k-switch" style="--k-switch-on: var(--k-warn)">…</label>

Range

keel styles all input type="range" elements from the base layer — no class needed. The thumb and track are fully themed: an accent thumb with a surface ring, a quiet pill track, and the usual soft accent ring on keyboard focus. The track color is the --k-range-track knob, so a tool (or you) can paint a gradient into it — the second slider below does exactly that with one inline knob.

Markup
<div class="k-field">
  <label for="volume">Volume</label>
  <input id="volume" type="range" min="0" max="100" value="60">
</div>

<!-- the track is a knob — paint it -->
<input type="range"
       style="--k-range-track: linear-gradient(90deg, var(--k-accent-soft), var(--k-accent))">

File input

A plain input type="file" needs no class at all — the base layer styles the native ::file-selector-button as a soft-accent button and quiets the filename text beside it. It's still the real control: keyboard, forms, and the picker dialog all untouched.

Markup
<div class="k-field">
  <label for="attachment">Attachment</label>
  <input id="attachment" type="file">
</div>

Color

input type="color" is styled from the base layer with no class — keel draws the well, its border, and its radius, so the swatch sits comfortably next to other fields. Honestly: the popup color wheel belongs to the OS and cannot be styled from CSS. That's the trade for a real, accessible control for free. When the design needs its own panel — shade area, rails, saved swatches — that's the color picker below.

Markup
<div class="k-field">
  <label for="accent">Accent color</label>
  <input id="accent" type="color" value="#4353e8">
</div>

Color picker

When the native color well isn't enough: .k-swatch is a color-well button, and .k-picker is the panel it opens — shade area, rails, and swatch rows, usually as a [popover] via popovertarget so Esc and light-dismiss come free. Honest split: the chrome here is keel CSS; the shade math, positioning, and saved-color storage are your JavaScript. The design page runs a complete reference implementation (view its play.js) — 2D drag, hue rail, recents, saved colors, EyeDropper, and a .k-picker__field format row that reads and writes Hex, RGB, HSL, HSV, and CMYK.

#4353e8
Saved

This demo is static — without the popover attribute a .k-picker renders inline, which is also how you'd embed one permanently.

Markup
<button class="k-swatch" popovertarget="picker"
        style="--k-swatch-color: #4353e8"
        aria-label="Choose a color"></button>

<div class="k-picker" id="picker" popover>
  <div class="k-picker__area">
    <span class="k-picker__cursor"></span>
  </div>
  <input type="range" min="0" max="360">   <!-- hue rail -->
  <div class="k-picker__row">
    <span class="k-picker__label">Saved</span>
    <button class="k-btn k-btn--ghost k-btn--small">+ Add</button>
  </div>
  <div class="k-picker__swatches">…</div>
</div>
<!-- wiring (drag math, storage, anchoring) is your JS;
     see the design page's play.js for a reference -->

Date & time

The native temporal inputs are styled from the base layer with no class — the field itself looks like every other keel field, and the calendar-picker indicator beside it is quieted until you hover it. Honestly: the popup calendar and clock belong to the browser and cannot be styled from CSS. Native inputs are free, accessible, localized, and keyboard-complete — use them by default.

Markup
<div class="k-field">
  <label for="launch">Launch date</label>
  <input id="launch" type="date">
</div>
<input type="time">
<input type="datetime-local">

The keel date field

For when the design must match: a readonly text input, a ghost icon button, and a .k-calendar inside a .k-picker popover. The chrome — field, button, panel, month grid — is all keel CSS; filling the input when a day is clicked is a few lines of your own JavaScript (the demo below ships exactly that, inline). You own that small script — and the localization and keyboard work the native input did for free. Try it: the calendar button opens a real popover, and the days write into the field.

A designed picker — keel chrome, your wiring.

August 2026
MoTuWeThFrSaSu
Markup
<div class="k-field">
  <label for="event-date">Event date</label>
  <div class="k-input-group">
    <input id="event-date" type="text" readonly placeholder="Pick a date">
    <button class="k-btn k-btn--ghost k-btn--icon"
            popovertarget="date-pop" aria-label="Open calendar">
      <span class="k-icon k-icon--calendar" aria-hidden="true"></span>
    </button>
  </div>
</div>

<div class="k-picker" id="date-pop" popover>
  <div class="k-calendar">
    <div class="k-calendar__head"><span>August 2026</span></div>
    <div class="k-calendar__grid">
      <abbr title="Monday">Mo</abbr> …
      <button class="k-calendar__day" data-date="2026-08-14">14</button> …
    </div>
  </div>
</div>

<script>
  // your wiring — day buttons fill the input and close the popover
  pop.querySelectorAll('.k-calendar__day[data-date]').forEach(day =>
    day.addEventListener('click', () => {
      input.value = day.dataset.date;
      pop.hidePopover();
    }));
</script>

Sizes

Two size modifiers, on the control itself: .k-input--small tightens the padding and drops to the small type step, .k-input--large loosens both. The default needs no class. They work on any control the base layer styles — inputs, selects, textareas.

Markup
<input type="text" class="k-input--small">
<input type="text">                        <!-- default -->
<input type="text" class="k-input--large">

<!-- works on any control -->
<select class="k-input--small">…</select>

States

All attribute-driven, no classes. disabled controls sink to the second surface, fade, and stop responding to the cursor. readonly inputs get the same quiet surface with a dashed border — visibly "you can read this, not edit it", and still focusable and copyable. For required fields, .k-field--required paints a danger asterisk after the label — but that's only the marker: the real work is the HTML required attribute, which drives native validation. Use both together.

Dashed border: readable, copyable, not editable.

Markup
<input type="text" disabled>
<select disabled>…</select>
<input type="text" value="KEEL-2041-88" readonly>

<!-- the class draws the asterisk; the attribute enforces it -->
<div class="k-field k-field--required">
  <label for="company">Company</label>
  <input id="company" type="text" required>
</div>

The full input matrix

Every condition a text input can be in, side by side. Four of these are real — the disabled, readonly, invalid, and valid fields below carry the actual attributes and values. Two belong to the browser and can't be forced from a stylesheet: hover it yourself to feel the border deepen, and the "focused" field is a static fake wearing the real ring styles inline, labeled (simulated). And one honesty note on validation: keel styles :user-invalid and :user-valid, which fire only after you interact — so the two prefilled fields sit quiet until you touch them. Click into each, type (or don't), then tab away and watch the border go danger or ok.

Surface, hairline border, soft inset.

Real state — the border deepens to --k-text-faint.

The real ring styles, worn inline — :focus-visible can't be forced. Click any field for the real thing.

Second surface, faded, cursor shows not-allowed.

Dashed border — readable, copyable, not editable.

The value is already bad, but :user-invalid waits for you — edit it, tab away, and the border goes danger.

Same rule: :user-valid fires after interaction. Touch it, tab away, and the border goes ok.

Markup
<input type="text">                    <!-- resting; hover and focus need no markup -->
<input type="text" disabled>
<input type="text" value="KEEL-2041-88" readonly>

<!-- :user-invalid / :user-valid fire only after interaction -->
<input type="email" required value="not-an-email">      <!-- danger after blur -->
<input type="email" required value="ada@example.com">   <!-- ok after blur -->

<!-- the "focused" cell above is simulated for display —
     the real ring is applied by :focus-visible:
     border-color: var(--k-accent);
     box-shadow: 0 0 0 3px var(--k-accent-trans-20) -->

Switch states

The switch is a real checkbox, so it carries the same conditions: on, off, and disabled are all real below — a disabled switch keeps its track color and simply fades. Keyboard focus draws the accent ring around the pill; that's browser-managed, so the last row is a static fake wearing the ring inline, labeled (simulated) — Tab to any real switch for the genuine article.

Markup
<label class="k-switch" style="display: flex; align-items: center; gap: var(--k-space-3)">
  <input type="checkbox" checked>  <!-- on -->
  <span>On</span>
</label>

<input type="checkbox">                   <!-- off -->
<input type="checkbox" checked disabled>  <!-- disabled: fades, stops taking input -->

<!-- the focus ring is :focus-visible — Tab to a switch to see it:
     box-shadow: 0 0 0 3px var(--k-accent-trans-20) -->

Validation

keel styles :user-invalid and :user-valid natively — the border goes danger or ok only after the user has interacted with the field, never on page load. Add a .k-field__error inside a .k-field and it stays hidden until the field is actually invalid; :has() does the showing, no script. The field's label turns danger at the same moment. Try it: type something that isn't an email below, then tab away.

Enter a valid email address.

Markup
<div class="k-field">
  <label for="email">Email</label>
  <input id="email" type="email" required aria-describedby="email-error">
  <p class="k-field__error" id="email-error">Enter a valid email address.</p>
</div>

Layout

Form rows: .k-form-row is a grid that stacks on mobile; add --2 or --3 and it splits into columns from the sm breakpoint up. Anything outside a row is simply full width. Below, a realistic address form: a two-column row, a three-column row, and a full-width message.

.k-field--inline lays the label and control on one line — handy for compact settings rows and filters.

For an input joined to a button — search bars, newsletter signups — use .k-input-group, documented with the other action patterns on Actions.

Markup
<div class="k-form-row k-form-row--2">
  <div class="k-field">…name…</div>
  <div class="k-field">…email…</div>
</div>

<div class="k-form-row k-form-row--3">
  <div class="k-field">…city…</div>
  <div class="k-field">…region…</div>
  <div class="k-field">…postal…</div>
</div>

<div class="k-field">…full-width message…</div>

<div class="k-field k-field--inline">
  <label for="sort">Sort by</label>
  <select id="sort">…</select>
</div>