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:

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 (layer slashed.states). Two accessibility-related entries (.no-motion, .sr-only-focusable) moved to core/accessibility.css (layer slashed.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:

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>