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.
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.
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
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.
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>