State classes (.sf-is-*)
State classes describe a runtime condition toggled by JS or mirrored from
ARIA attributes — not static styling intent (that’s for components/utilities/layout helpers such as .sf-overlay).
They live in the slashed.states layer, are single-class / low-specificity,
and set the minimum needed to communicate the state; components layer
visuals on top.
All states are exercised live in the demo.
Prefer native state
A .sf-is-* class is a fallback for when the platform has no state mechanism
of its own — not the default reach. Before reaching for one, check whether a
native pseudo-class, attribute, or ARIA state already gets you there:
| Instead of | Reach for | Why |
|---|---|---|
| a hidden-state class | [hidden] |
native, and already hardened to display: none !important in core/reset.css |
| a disabled-state class | :disabled / [disabled] |
native on every real form control and <button> |
| a readonly-state class | :read-only |
native on real inputs/textareas; doesn’t block text selection the way a hand-rolled pointer-events: none does |
| an empty-state class | :empty |
native, zero JS, reacts to DOM content automatically |
| a validation-state class | :user-invalid / :invalid / [aria-invalid] |
native for client-side constraint validation (see forms.css and .sf-live-validate) |
| a busy-cursor-only class | [aria-busy="true"] |
you set this for assistive tech anyway, so it’s sufficient as the sole hook — no parallel class needed |
| a selected class | [aria-selected] (custom widgets) |
required for accessible custom widgets regardless of framework, so styling off the attribute you must already set avoids toggling two things for one state |
.sf-is-* earns its place only when no native mechanism reaches the same
condition (.sf-is-loading’s spinner, .sf-is-shimmer’s shimmer, the
drag-and-drop trio — CSS has no native drag state at all) or when the class
is a token setter consumed elsewhere in the framework (the validation
family writing --sf-field-*, read by optional/forms.css). Where a class
and a native attribute can coexist for different purposes — e.g. an element
carrying both .sf-is-invalid (a manual/server-side override) and
:user-invalid (the browser’s own opinion) — cascade layer order lets the
manual class win regardless of specificity; see “Wiring validation text
colour” below.
Two rounds of removals came out of holding every class to this bar:
.sf-is-hidden,.sf-is-readonly, and.sf-is-busyduplicated a native mechanism outright:.sf-is-hiddenduplicated[hidden]byte-for-byte (both this framework’s owncore/reset.cssand the browser already give you that),.sf-is-readonlyduplicated:read-onlywhile also blocking text selection a real read-only field should allow, and.sf-is-busywas a singlecursor: progresswith no distinct visual —[aria-busy="true"]alone covers the same ground..sf-is-active,.sf-is-open,.sf-is-collapsed,.sf-is-expanded,.sf-is-pressed, and.sf-is-currentonly ever set an inheritable--sf-is-*custom property for a hypothetical consumer’s owncalc()to branch on — a speculative “might be useful one day” mechanism with zero consumers anywhere in this codebase, its demo, or in any comparable framework surveyed (Open Props, Pico.css, Bulma, Automatic.css ship nothing like it)..sf-is-dangerwas dropped alongside them: identical CSS to.sf-is-invalid/.sf-is-error, so it never actually worked as the “destructive-action context” its own docs described outside a form field, anddocs/migration.mdalready tells you visual variants belong in your own component CSS..sf-is-pendingwent too — two lines (opacity,cursor: progress) cheap enough to hand-roll, with.sf-is-loadingalready covering the more common “mask with a spinner” case..sf-is-invisible/.sf-is-visibleweren’t dropped, but relocated: they’re single-property helpers with no runtime behaviour of their own (unlike.sf-is-selected, which paints something), so they moved tooptional/utilities.cssas.sf-invisible/.sf-visible.
None of this was “these are accidental duplicates” — several had a written,
deliberate rationale (see the git history of this file for the “shown vs
hidden surface” vs “disclosure trigger” distinction .sf-is-open vs
.sf-is-expanded used to carry, or the optimistic-UI case for
.sf-is-pending). But intentional and needed at this stage are
different bars. A pre-1.0, lean CSS framework should carry what most
consumers need today, not infrastructure justified only by “someone might
want to build X on top of it” — that’s cheap to add back once a real
consumer pattern actually asks for it, and expensive to carry speculatively
in the meantime (tests, docs, configurator preview panels, and the
“why does this exist” question every future contributor has to re-answer).
Reference
Layer note: Most state classes live in
core/states.css(layerslashed.states). Two accessibility-related entries (.no-motion,.sr-only-focusable) moved tocore/accessibility.css(layerslashed.accessibility) for better layering priority.
| Class | Use when | ARIA / pairing | Layer |
|---|---|---|---|
.sf-is-disabled |
non-interactive, dimmed — for elements that can’t take the native disabled attribute (e.g. <a class="sf-btn">) |
aria-disabled="true" |
states |
.sf-is-loading |
content replaced by a spinner | aria-busy="true" |
states |
.sf-is-shimmer |
placeholder shimmer | — | states |
.sf-is-selected |
selected in a set | aria-selected |
states |
.sf-is-highlighted |
transient emphasis | — | states |
.sf-is-valid / .sf-is-invalid |
form-field validation result | aria-invalid |
states |
.sf-is-success / .sf-is-error |
general positive/negative feedback (a save, a step) | role="status" / role="alert" |
states |
.sf-is-warning / .sf-is-info |
cautionary / informational feedback | — | states |
.sf-is-dragging / .sf-is-drop-target / .sf-is-draggable |
drag & drop | — | states |
.sf-is-empty:empty |
hide when empty | — | states |
.sf-invisible / .sf-visible |
hidden but keeps its box | visibility |
utilities |
.sr-only-focusable |
hidden until focused (skip-link pattern) | — | accessibility |
.no-motion |
kill all animation/transition on this subtree | prefers-reduced-motion equivalent |
accessibility |
.sf-focus-parent (in core/accessibility.css): a container that rings when any
descendant has keyboard focus (:focus-within).
.sf-focus-shadow (in core/accessibility.css): switches the :focus-visible
indicator from the default outline ring to a box-shadow ring
(var(--sf-focus-ring-shadow)), for rounded or overflow: hidden elements where
an outline would clip. Uses !important to survive unlayered resets.
Disambiguating the overlaps
These pairs look similar but signal different intent — pick by context:
.sf-is-invalidvs.sf-is-error—.sf-is-invalidis for a form field that failed validation (maps toaria-invalid);.sf-is-erroris general component feedback not tied to one field (a failed network save, a broken widget)..sf-is-validvs.sf-is-success— same split on the positive side: field validation passed vs a general positive outcome.
For a destructive-action button (delete, “this cannot be undone”), or a
disclosure trigger’s expanded/collapsed state, use your own component
modifier (.c-button--danger) and/or the matching ARIA attribute
(aria-expanded) — see “Prefer native state” above.
Consumer responsibility (CSS can’t do it for you)
State classes are visual. Always pair them with the matching ARIA attribute so
assistive tech is informed — e.g. class="sf-is-selected" aria-selected="true".
Live updates (a toast appearing, a validation message) need an ARIA live region
(aria-live, role="status"/"alert"); CSS cannot announce changes.
Wiring validation text colour
Validation states (.sf-is-valid, .sf-is-invalid, .sf-is-error, etc.) set two
tokens: --sf-field-border-color (consumed by forms.css automatically) and
--sf-field-text-color (a consumer hook).
The framework recolours the border for you. For text (error messages, labels, helper hints), wire the token in your own component CSS:
.form-error,
.form-helper {
color: var(--sf-field-text-color, inherit);
}
Because the token inherits, place the .sf-is-* class on a wrapper and every
descendant picks it up — regardless of whether your pattern uses visible
labels, sr-only labels with placeholders, or a custom markup structure:
<div class="field-group sf-is-invalid">
<label class="sr-only">Email</label>
<input type="email" placeholder="Email">
<span class="form-error">Please enter a valid email</span>
</div>