Field

Label, control and message as one unit. This is the component that forces the rest of the system to settle: control height, focus ring, border colours, error colour and vertical rhythm are all decided here and reused everywhere else.

Reference

sk-field

The wrapper. A column of label, control and messages - which is why dropping one into a flex row needs end alignment, or an sk-input-group instead.

<div class="sk-field">
  <label class="sk-label" for="id">Label</label>
  <input class="sk-input" id="id" name="name">
  <p class="sk-hint" id="id-hint">Hint</p>
  <p class="sk-error" id="id-error" role="alert">Error</p>
</div>

sk-input, sk-textarea, sk-select

The controls. Validity is expressed as aria-invalid, never as a modifier class - the CSS keys off the same attribute a screen reader reads, so the two cannot drift apart.

<input class="sk-input" id="id" name="name"
       aria-invalid="false"
       aria-describedby="id-hint">
AttributeTypeDefaultNotes
data-size"sm" | "md" | "lg""md"Heights come from the shared control tokens, so sizes line up across components.
aria-invalid"true" | "false""false"true switches the border to the danger colour. This is the only invalid state - there is no .is-error.
aria-describedbyspace-separated ids-Points at the hint, the error, or both. The binding omits it entirely when there is nothing to describe rather than emitting a dangling reference.
disabled, readonly, requiredboolean attributes-Native. required renders the asterisk via the label, not the control.
rowsnumber"4"sk-textarea only. Resizes vertically only - horizontal resize breaks the layout it sits in.

sk-input-group

A control with something attached: a button, an affix. Put it inside the field, below the label, and the button is aligned by construction - no end-alignment flag to forget at the call site.

<div class="sk-field">
  <label class="sk-label" for="id">Label</label>
  <div class="sk-input-group">
    <span class="sk-input-affix">https://</span>
    <input class="sk-input" id="id">
    <button class="sk-button">Go</button>
  </div>
</div>
AttributeTypeDefaultNotes
data-attached"false"attachedOmit to join the edges into one control. Set to "false" to keep separate corners and a gap.

sk-check

Checkbox and radio. Uses accent-color rather than a custom control, so the native focus and checked states come for free.

<label class="sk-check" for="id">
  <input type="checkbox" id="id" name="name">
  <span class="sk-check-label">Label</span>
</label>

sk-fieldset, sk-legend

The grouped-choice control, and the reason it cannot be a Field: `for` names exactly one element, so a "Plan" label above three radios either names the first one - leaving the other two anonymous - or names nothing at all. A <legend> names a GROUP, and every option inside is then announced as belonging to it. The UA's border and inset legend are reset; a fieldset also defaults to min-content, which is what the min-inline-size: 0 is for.

<fieldset class="sk-fieldset">
  <legend class="sk-legend">Plan</legend>
  <div class="sk-choices">
    <label class="sk-check">…</label>
  </div>
</fieldset>
AttributeTypeDefaultNotes
data-layout"inline"stackedOn sk-choices. Two or three short options only: in a row the gap between one label and the next radio has to stay visibly smaller than the gap within a pair.
aria-invalid"true"-On the fieldset, not on each radio - the group is what is invalid.
disabledboolean-On the fieldset, disables every control inside it. One attribute rather than one per option.

sk-switch

A checkbox with role="switch". USE IT FOR SETTINGS THAT TAKE EFFECT AT ONCE - a switch says the thing is now on, a checkbox says the value will be included when you submit, and a switch inside a form with a Save button promises something the form does not do. The input is a real checkbox stretched invisibly over the whole row rather than hidden, so it keeps its focus behaviour, its keyboard handling and its place in the form; every visual state is a sibling selector away from it.

<div class="sk-switch">
  <input type="checkbox" role="switch" id="id">
  <span class="sk-switch-track" aria-hidden="true"></span>
  <span class="sk-switch-text">
    <label class="sk-switch-label" for="id">Label</label>
  </span>
</div>
AttributeTypeDefaultNotes
role"switch"-Not decoration. It changes the announcement from "checked" to "on", which is the difference the control is claiming.
sk-switch-hintelement-Referenced with aria-describedby rather than nested in the label: text inside a label becomes part of the control's NAME, and a sentence is not a name.

sk-segmented

Radios that look like a ButtonGroup, and the difference is what happens when you use one. ButtonGroup's toggle is buttons carrying aria-pressed; buttons do not submit a value, so that control cannot be part of a form unless the application turns each one into a link or wires script to it. These are radios: they submit, they come back from the server on reload, arrow keys move between them, and the set is one tab stop rather than four. The selected segment is a raised surface on a sunken track rather than the kiwi fill - a segmented control chooses between views, and filling it kiwi puts a second primary button on the screen.

<div class="sk-segmented">
  <label class="sk-segment">
    <input type="radio" name="view" value="grid" checked>
    <span class="sk-segment-label">Grid</span>
  </label>
</div>
AttributeTypeDefaultNotes
data-size"sm" | "md" | "lg""md"Heights come from the shared control tokens, so sizes line up across components.
data-block"true"-Fills the row with equal segments, for the top of a narrow panel where an inline block hugging its content looks stranded.
data-legend"hidden"-On the fieldset. Keeps the group's name in the accessibility tree and off the screen, for a toolbar where the segments say what the control is. The name still has to exist - without one the group is announced as nothing.

sk-range

accent-color rather than three sets of vendor pseudo-elements: restyling a range means ::-webkit-slider-runnable-track, ::-moz-range-track, both thumbs, and the rule that one unknown selector invalidates the whole list - and the reward is a slider that behaves subtly differently from every other one on the reader's machine. The darker action step is used, not --sk-accent: kiwi-400 is 1.6:1 against white and a control needs 3:1.

<input class="sk-range" type="range" id="id" min="0" max="100" step="5">
AttributeTypeDefaultNotes
aria-valuetextstring-"3 of 5", "Medium". A slider that announces a bare number is a number with no subject.
value readout--There is none: keeping one in sync needs script. Where the exact number matters, render it server-side or use a number input - a control that needs an exact value was never a slider.

sk-input[type="file"]

The one input the UA draws as a button plus a filename, so it takes auto height and its own padding instead of the shared control height. ::file-selector-button is restyled in place rather than replaced - hiding the input behind a styled label is the usual trick and it costs the filename display, the drop target and the native focus behaviour.

<input class="sk-input" type="file" id="id" accept=".csv">
AttributeTypeDefaultNotes
acceptstring-A filter, not a validation: the operating system's dialog lets the reader override it, so the server checks the type regardless.
multipleboolean-

sk-hint, sk-error

Messages. The error carries role="alert" so a message swapped in after a failed submit is announced rather than silently appearing.

<p class="sk-hint" id="id-hint">Supporting text</p>
<p class="sk-error" id="id-error" role="alert">What went wrong</p>

Examples

Anatomy

Open the markup panel: the label is associated with the control, the hint is referenced by aria-describedby, and there is no wrapper div doing layout work that a layout primitive should be doing.

Shown on your public profile.

Show markup
<div class="sk-field">
  <label class="sk-label" for="display-name">
    Display name
  </label>
  <input class="sk-input" id="display-name" name="display-name" type="text" value="" placeholder="Placeholder" data-size="md" aria-invalid="false" aria-describedby="display-name-hint">
  <p class="sk-hint" id="display-name-hint">
    Shown on your public profile.
  </p>
</div>

Invalid

Validity is expressed as aria-invalid, never as an .is-error class, and the CSS keys off the same attribute. That is the whole trick: the visual state and the state a screen reader announces cannot drift apart, and any binding - templ, Svelte, hand-written HTML - has exactly one thing to set.

Lowercase letters, numbers and dashes.

Show markup
<div class="sk-flex sk-flex-col sk-gap-4">
  <div class="sk-field">
    <label class="sk-label" for="email">
      Email
    </label>
    <input class="sk-input" id="email" name="email" type="email" value="not-an-email" data-size="md" aria-invalid="true" aria-describedby="email-error">
    <p class="sk-error" id="email-error" role="alert">
      <span aria-hidden="true">
        !
      </span>
      Enter a valid email address.
    </p>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="handle">
      Handle
    </label>
    <input class="sk-input" id="handle" name="handle" type="text" value="with spaces" data-size="md" aria-invalid="true" aria-describedby="handle-hint handle-error">
    <p class="sk-hint" id="handle-hint">
      Lowercase letters, numbers and dashes.
    </p>
    <p class="sk-error" id="handle-error" role="alert">
      <span aria-hidden="true">
        !
      </span>
      Handles cannot contain spaces.
    </p>
  </div>
</div>

Input group

The fix for a button beside a control. Because the button sits inside the field rather than beside it, there is no alignment flag to get wrong.

https://

An affix is static text attached to the control.

Show markup
<div class="sk-flex sk-flex-col sk-gap-5">
  <div class="sk-field">
    <label class="sk-label" for="signup">
      Email
    </label>
    <div class="sk-input-group">
      <input class="sk-input" id="signup" name="signup" type="email" value="" placeholder="you@example.com" data-size="md" aria-invalid="false">
      <button class="sk-button" type="button" data-variant="solid" data-size="md">
        Subscribe
      </button>
    </div>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="site">
      Website
    </label>
    <div class="sk-input-group">
      <span class="sk-input-affix">
        https://
      </span>
      <input class="sk-input" id="site" name="site" type="text" value="" placeholder="example.com" data-size="md" aria-invalid="false">
    </div>
    <p class="sk-hint" id="site-hint">
      An affix is static text attached to the control.
    </p>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="search">
      Search
    </label>
    <div class="sk-input-group" data-attached="false">
      <input class="sk-input" id="search" name="search" type="search" value="" placeholder="Query" data-size="md" aria-invalid="false">
      <button class="sk-button" type="button" data-variant="neutral" data-size="md">
        Search
      </button>
    </div>
  </div>
</div>

Sizes

Show markup
<div class="sk-flex sk-flex-col sk-gap-4">
  <div class="sk-field">
    <label class="sk-label" for="sm">
      Small
    </label>
    <input class="sk-input" id="sm" name="sm" type="text" value="" placeholder="sm" data-size="sm" aria-invalid="false">
  </div>
  <div class="sk-field">
    <label class="sk-label" for="md">
      Medium
    </label>
    <input class="sk-input" id="md" name="md" type="text" value="" placeholder="md" data-size="md" aria-invalid="false">
  </div>
  <div class="sk-field">
    <label class="sk-label" for="lg">
      Large
    </label>
    <input class="sk-input" id="lg" name="lg" type="text" value="" placeholder="lg" data-size="lg" aria-invalid="false">
  </div>
</div>

Disabled and read-only

Show markup
<div class="sk-flex sk-flex-col sk-gap-4">
  <div class="sk-field">
    <label class="sk-label" for="locked">
      Disabled
    </label>
    <input class="sk-input" id="locked" name="locked" type="text" value="Cannot edit" data-size="md" aria-invalid="false" disabled>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="readonly">
      Read-only
    </label>
    <input class="sk-input" id="readonly" name="readonly" type="text" value="Can select and copy" data-size="md" aria-invalid="false" readonly>
  </div>
</div>

Textarea

Resizes vertically only - horizontal resize breaks the layout it sits in.

Show markup
<div class="sk-field">
  <label class="sk-label" for="description">
    Description
  </label>
  <textarea class="sk-textarea" id="description" name="description" rows="4" placeholder="Placeholder" aria-invalid="false" aria-describedby="description-hint">
  </textarea>
  <p class="sk-hint" id="description-hint">
    Resizes vertically only - horizontal resize breaks the layout it sits in.
  </p>
</div>

Select

The arrow is drawn with gradients rather than an SVG, so the bundle stays asset-free apart from the fonts.

Show markup
<div class="sk-flex sk-flex-col sk-gap-4">
  <div class="sk-field">
    <label class="sk-label" for="option">
      Choose an option
      <span class="sk-required" aria-hidden="true">
        *
      </span>
    </label>
    <select class="sk-select" id="option" name="option" data-size="md" aria-invalid="false" required>
      <option value="" disabled selected>
        Select one
      </option>
      <option value="a">
        First option
      </option>
      <option value="b">
        Second option
      </option>
      <option value="c">
        Third option
      </option>
      <option value="d" disabled>
        Unavailable option
      </option>
    </select>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="invalid-select">
      With an error
    </label>
    <select class="sk-select" id="invalid-select" name="invalid-select" data-size="md" aria-invalid="true" aria-describedby="invalid-select-error">
      <option value="a">
        First option
      </option>
      <option value="b" selected>
        Second option
      </option>
    </select>
    <p class="sk-error" id="invalid-select-error" role="alert">
      <span aria-hidden="true">
        !
      </span>
      That option is no longer available.
    </p>
  </div>
</div>

Checkbox and radio

Show markup
<div class="sk-flex sk-flex-col sk-gap-3">
  <label class="sk-check" for="check-a">
    <input id="check-a" name="check-a" type="checkbox" value="" checked>
    <span class="sk-check-label">
      Checked
    </span>
  </label>
  <label class="sk-check" for="check-b">
    <input id="check-b" name="check-b" type="checkbox" value="">
    <span class="sk-check-label">
      Unchecked
    </span>
  </label>
  <label class="sk-check" for="check-c">
    <input id="check-c" name="check-c" type="checkbox" value="" disabled>
    <span class="sk-check-label">
      Disabled
    </span>
  </label>
  <label class="sk-check" for="radio-a">
    <input id="radio-a" name="radio" type="radio" value="a" checked>
    <span class="sk-check-label">
      First choice
    </span>
  </label>
  <label class="sk-check" for="radio-b">
    <input id="radio-b" name="radio" type="radio" value="b">
    <span class="sk-check-label">
      Second choice
    </span>
  </label>
</div>

Grouped choices

A fieldset and a legend, which is the only construct that names a group. Open the markup panel and compare it with the loose radios above: there, each option is named and the question is not.

Plan

You can change this at any time.

Billing period
Notify me about

A set of checkboxes is a group too - one question, several answers.

Show markup
<div class="sk-flex sk-flex-col sk-gap-6">
  <fieldset class="sk-fieldset" id="plan" aria-describedby="plan-hint">
    <legend class="sk-legend">
      Plan
    </legend>
    <div class="sk-choices">
      <label class="sk-check" for="plan-free">
        <input id="plan-free" name="plan" type="radio" value="free">
        <span class="sk-check-label">
          Free - one project
        </span>
      </label>
      <label class="sk-check" for="plan-team">
        <input id="plan-team" name="plan" type="radio" value="team" checked>
        <span class="sk-check-label">
          Team - unlimited projects
        </span>
      </label>
      <label class="sk-check" for="plan-enterprise">
        <input id="plan-enterprise" name="plan" type="radio" value="enterprise">
        <span class="sk-check-label">
          Enterprise - talk to us
        </span>
      </label>
    </div>
    <p class="sk-hint" id="plan-hint">
      You can change this at any time.
    </p>
  </fieldset>
  <fieldset class="sk-fieldset" id="billing" aria-invalid="true" aria-describedby="billing-error">
    <legend class="sk-legend">
      Billing period
      <span class="sk-required" aria-hidden="true">
        *
      </span>
    </legend>
    <div class="sk-choices" data-layout="inline">
      <label class="sk-check" for="billing-monthly">
        <input id="billing-monthly" name="billing" type="radio" value="monthly">
        <span class="sk-check-label">
          Monthly
        </span>
      </label>
      <label class="sk-check" for="billing-yearly">
        <input id="billing-yearly" name="billing" type="radio" value="yearly">
        <span class="sk-check-label">
          Yearly
        </span>
      </label>
    </div>
    <p class="sk-error" id="billing-error" role="alert">
      <span aria-hidden="true">
        !
      </span>
      Choose a billing period.
    </p>
  </fieldset>
  <fieldset class="sk-fieldset" id="notify" aria-describedby="notify-hint">
    <legend class="sk-legend">
      Notify me about
    </legend>
    <div class="sk-choices">
      <label class="sk-check" for="notify-builds">
        <input id="notify-builds" name="notify-builds" type="checkbox" value="" checked>
        <span class="sk-check-label">
          Failed builds
        </span>
      </label>
      <label class="sk-check" for="notify-releases">
        <input id="notify-releases" name="notify-releases" type="checkbox" value="">
        <span class="sk-check-label">
          Releases
        </span>
      </label>
      <label class="sk-check" for="notify-digest">
        <input id="notify-digest" name="notify-digest" type="checkbox" value="">
        <span class="sk-check-label">
          Weekly digest
        </span>
      </label>
    </div>
    <p class="sk-hint" id="notify-hint">
      A set of checkboxes is a group too - one question, several answers.
    </p>
  </fieldset>
</div>

Switch

Tab to one and press Space. It is a checkbox underneath - stretched invisibly over the whole row, so it keeps every behaviour a real control has.

Ships unfinished things early. Turn it off if a build starts behaving oddly.
Show markup
<div class="sk-flex sk-flex-col sk-gap-5">
  <div class="sk-switch">
    <input type="checkbox" role="switch" id="switch-notify" name="switch-notify" checked>
    <span class="sk-switch-track" aria-hidden="true">
    </span>
    <span class="sk-switch-text">
      <label class="sk-switch-label" for="switch-notify">
        Email notifications
      </label>
    </span>
  </div>
  <div class="sk-switch">
    <input type="checkbox" role="switch" id="switch-beta" name="switch-beta" aria-describedby="switch-beta-hint">
    <span class="sk-switch-track" aria-hidden="true">
    </span>
    <span class="sk-switch-text">
      <label class="sk-switch-label" for="switch-beta">
        Beta features
      </label>
      <span class="sk-switch-hint" id="switch-beta-hint">
        Ships unfinished things early. Turn it off if a build starts behaving oddly.
      </span>
    </span>
  </div>
  <div class="sk-switch">
    <input type="checkbox" role="switch" id="switch-locked" name="switch-locked" checked disabled>
    <span class="sk-switch-track" aria-hidden="true">
    </span>
    <span class="sk-switch-text">
      <label class="sk-switch-label" for="switch-locked">
        Enforced by your workspace
      </label>
    </span>
  </div>
</div>

Segmented control

Radios underneath: tab in once, then use the arrow keys. The third one hides its legend, which is what a toolbar wants - the name is still announced.

View
Period

Submits with the form, and comes back selected on reload.

Density
Show markup
<div class="sk-flex sk-flex-col sk-gap-6">
  <fieldset class="sk-fieldset" id="view">
    <legend class="sk-legend">
      View
    </legend>
    <div class="sk-segmented">
      <label class="sk-segment">
        <input type="radio" id="view-grid" name="view" value="grid" checked>
        <span class="sk-segment-label">
          Grid
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="view-list" name="view" value="list">
        <span class="sk-segment-label">
          List
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="view-table" name="view" value="table">
        <span class="sk-segment-label">
          Table
        </span>
      </label>
    </div>
  </fieldset>
  <fieldset class="sk-fieldset" id="period" aria-describedby="period-hint">
    <legend class="sk-legend">
      Period
    </legend>
    <div class="sk-segmented" data-size="sm">
      <label class="sk-segment">
        <input type="radio" id="period-day" name="period" value="day">
        <span class="sk-segment-label">
          Day
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="period-week" name="period" value="week" checked>
        <span class="sk-segment-label">
          Week
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="period-month" name="period" value="month">
        <span class="sk-segment-label">
          Month
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="period-year" name="period" value="year" disabled>
        <span class="sk-segment-label">
          Year
        </span>
      </label>
    </div>
    <p class="sk-hint" id="period-hint">
      Submits with the form, and comes back selected on reload.
    </p>
  </fieldset>
  <fieldset class="sk-fieldset" id="density" data-legend="hidden">
    <legend class="sk-legend">
      Density
    </legend>
    <div class="sk-segmented" data-block="true">
      <label class="sk-segment">
        <input type="radio" id="density-comfortable" name="density" value="comfortable" checked>
        <span class="sk-segment-label">
          Comfortable
        </span>
      </label>
      <label class="sk-segment">
        <input type="radio" id="density-compact" name="density" value="compact">
        <span class="sk-segment-label">
          Compact
        </span>
      </label>
    </div>
  </fieldset>
</div>

Range

Affects enemy count, not damage.

Show markup
<div class="sk-flex sk-flex-col sk-gap-5">
  <div class="sk-field">
    <label class="sk-label" for="volume">
      Music volume
    </label>
    <input class="sk-range" type="range" id="volume" name="volume" value="70" max="100">
  </div>
  <div class="sk-field">
    <label class="sk-label" for="difficulty">
      Difficulty
    </label>
    <input class="sk-range" type="range" id="difficulty" name="difficulty" value="3" min="1" max="5" step="1" aria-valuetext="3 of 5 - Normal" aria-describedby="difficulty-hint">
    <p class="sk-hint" id="difficulty-hint">
      Affects enemy count, not damage.
    </p>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="locked-range">
      Disabled
    </label>
    <input class="sk-range" type="range" id="locked-range" name="locked-range" value="40" disabled>
  </div>
</div>

File

PNG or JPEG, up to 2MB.

Show markup
<div class="sk-flex sk-flex-col sk-gap-5">
  <div class="sk-field">
    <label class="sk-label" for="avatar">
      Avatar
    </label>
    <input class="sk-input" type="file" id="avatar" name="avatar" accept="image/png,image/jpeg" aria-invalid="false" aria-describedby="avatar-hint">
    <p class="sk-hint" id="avatar-hint">
      PNG or JPEG, up to 2MB.
    </p>
  </div>
  <div class="sk-field">
    <label class="sk-label" for="attachments">
      Attachments
    </label>
    <input class="sk-input" type="file" id="attachments" name="attachments" multiple aria-invalid="true" aria-describedby="attachments-error">
    <p class="sk-error" id="attachments-error" role="alert">
      <span aria-hidden="true">
        !
      </span>
      One of those files is over the size limit.
    </p>
  </div>
</div>

Date and time

Nothing to restyle: these are text inputs with an editable value, and they already take the shared height, border and focus ring. The picker button is the only part that gets touched.

Show markup
<div class="sk-flex sk-flex-wrap sk-gap-4">
  <div class="sk-field">
    <label class="sk-label" for="starts">
      Starts
    </label>
    <input class="sk-input" id="starts" name="starts" type="date" value="2026-08-16" data-size="md" aria-invalid="false">
  </div>
  <div class="sk-field">
    <label class="sk-label" for="at">
      At
    </label>
    <input class="sk-input" id="at" name="at" type="time" value="09:30" data-size="md" aria-invalid="false">
  </div>
  <div class="sk-field">
    <label class="sk-label" for="deadline">
      Deadline
    </label>
    <input class="sk-input" id="deadline" name="deadline" type="datetime-local" value="" data-size="md" aria-invalid="false">
  </div>
</div>