ux-component-states

Interactive component state patterns including hover, focus, active, disabled, loading, and error states. Use when building buttons, inputs, cards, or any interactive elements. (project)

Installs: 0
Used in: 1 repos
Updated: 1d ago
$npx ai-builder add skill matthewharwood/ux-component-states

Installs to .claude/skills/ux-component-states/

# UX Component States Skill

Interactive state management for web components. This skill covers visual feedback for all interaction states, ensuring components feel responsive and accessible.

## Core States

### Default State

The baseline appearance when no interaction occurs:

```css
.button {
  background: var(--theme-surface-variant);
  color: var(--theme-on-surface);
  border: 1px solid var(--theme-outline);
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease;
}
```

### Hover State

Visual feedback when pointer enters element:

```css
.button:hover:not(:disabled) {
  background: var(--color-hover-overlay);
  border-color: var(--theme-outline);
}

/* For primary buttons */
.button-primary:hover:not(:disabled) {
  background: var(--theme-on-primary-container);
}
```

### Focus State

Visible focus for keyboard navigation (use `:focus-visible`):

```css
.button:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

/* Alternative with box-shadow */
.input:focus {
  border-color: var(--theme-primary);
  box-shadow: 0 0 0 3px var(--color-active-overlay);
}
```

### Active/Pressed State

Feedback during click/tap:

```css
.button:active:not(:disabled) {
  transform: scale(0.98);
  background: var(--color-active-overlay);
}
```

### Disabled State

Non-interactive appearance:

```css
.button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  pointer-events: none;
}

/* Alternative: muted colors */
.button:disabled {
  background: var(--theme-surface);
  color: var(--theme-on-surface-variant);
  border-color: var(--theme-outline-variant);
}
```

## Extended States

### Loading State

When awaiting response:

```css
.button[aria-busy="true"] {
  position: relative;
  color: transparent;
  pointer-events: none;
}

.button[aria-busy="true"]::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 1em;
  height: 1em;
  border: 2px solid var(--theme-on-primary);
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { rotate: 360deg; }
}
```

### Selected/Active (Toggle)

For toggleable or selectable items:

```css
.tab[aria-selected="true"] {
  background: var(--theme-primary);
  color: var(--theme-on-primary);
  font-weight: 600;
}

.checkbox[aria-checked="true"] {
  background: var(--theme-primary);
  border-color: var(--theme-primary);
}
```

### Current/Active Navigation

For indicating current location:

```css
.nav-item[aria-current="page"] {
  background: var(--theme-primary);
  color: var(--theme-on-primary);
}

.step[aria-current="step"] {
  color: var(--theme-primary);
  font-weight: 600;
}
```

### Error State

For validation failures:

```css
.input[aria-invalid="true"] {
  border-color: var(--color-error);
}

.input[aria-invalid="true"]:focus {
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}

.error-message {
  color: var(--color-error);
  font-size: var(--step--1);
}
```

### Success State

For completed or correct items:

```css
.input[data-valid="true"] {
  border-color: var(--color-success);
}

.item[data-completed] {
  color: var(--color-success);
}

.item[data-completed] .icon {
  color: var(--color-success);
}

/* Add check icon via Material Symbols */
/* <span class="icon" aria-hidden="true">check</span> */
```

### Locked/Unavailable State

For items not yet accessible:

```css
.item[data-locked] {
  opacity: 0.5;
  cursor: not-allowed;
  filter: grayscale(0.5);
}

.item[data-locked] .icon {
  /* Use Material Symbol: lock */
  /* <span class="icon" aria-hidden="true">lock</span> */
}
```

### Undefined State (Web Components)

Custom elements are "undefined" until JavaScript loads and registers them. Use `:not(:defined)` to prevent Flash of Unstyled Content (FOUC):

```css
/* Hide custom elements before JavaScript defines them */
site-nav:not(:defined),
word-card:not(:defined) {
  opacity: 0;
}

/* Reserve layout space for fixed elements to prevent CLS */
site-nav:not(:defined) {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  min-height: calc(var(--nav-height, 56px) + var(--space-s) * 2);
  background: var(--theme-surface);
}

/* Show once defined */
site-nav:defined {
  opacity: 1;
}
```

**Why `opacity: 0` instead of `display: none`?**
- `opacity: 0` preserves layout space, preventing CLS (Cumulative Layout Shift)
- `display: none` causes layout shift when element appears
- Reserve actual dimensions for fixed/positioned elements

**Combine with modulepreload for best results:**
```html
<link rel="modulepreload" href="/js/components/site-nav.js">
```

See **`web-components`** skill for complete FOUC prevention patterns.

## State Attribute Patterns

### Use Data Attributes for Custom States

```javascript
// In component
this.dataset.status = 'loading';  // [data-status="loading"]
this.dataset.status = 'complete'; // [data-status="complete"]
this.dataset.status = 'error';    // [data-status="error"]
```

```css
[data-status="loading"] { /* ... */ }
[data-status="complete"] { /* ... */ }
[data-status="error"] { /* ... */ }
```

### Use ARIA for Semantic States

```javascript
// Toggle pressed state
this.setAttribute('aria-pressed', this.#pressed);

// Set disabled
this.setAttribute('aria-disabled', 'true');

// Set busy/loading
this.setAttribute('aria-busy', 'true');

// Set invalid
this.internals.setValidity({ valueMissing: true }, 'Required');
this.setAttribute('aria-invalid', 'true');
```

## State Transitions

### Smooth Transitions

```css
.interactive {
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease,
    transform 0.1s ease,
    opacity 0.15s ease;
}
```

### Respect Reduced Motion

```css
@media (prefers-reduced-motion: reduce) {
  .interactive {
    transition: none;
  }
}
```

## Component Examples

### Button States

```css
.button {
  /* Default */
  background: var(--theme-surface-variant);
  border: 1px solid var(--theme-outline);
  color: var(--theme-on-surface);
  transition: all 0.15s ease;
}

.button:hover:not(:disabled) {
  background: var(--color-hover-overlay);
}

.button:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.button:active:not(:disabled) {
  transform: scale(0.98);
}

.button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
```

### Input States

```css
.input {
  /* Default */
  border: 1px solid var(--theme-outline);
  background: var(--theme-surface-variant);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.input:hover:not(:disabled) {
  border-color: var(--theme-outline);
}

.input:focus {
  outline: none;
  border-color: var(--theme-primary);
  box-shadow: 0 0 0 3px var(--color-active-overlay);
}

.input:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.input[aria-invalid="true"] {
  border-color: var(--color-error);
}

.input[aria-invalid="true"]:focus {
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}
```

### Card States

```css
.card {
  /* Default */
  background: var(--theme-surface-variant);
  border: 1px solid var(--theme-outline-variant);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.card:hover {
  border-color: var(--theme-outline);
}

.card:focus-within {
  border-color: var(--theme-primary);
}

.card[data-selected] {
  border-color: var(--theme-primary);
  box-shadow: 0 0 0 2px var(--theme-primary);
}
```

### Progress Indicator States

```css
.progress-dot {
  /* Pending */
  background: var(--theme-outline-variant);
}

.progress-dot[data-status="current"] {
  background: var(--theme-primary);
  animation: pulse 1.5s ease-in-out infinite;
}

.progress-dot[data-status="complete"] {
  background: var(--color-success);
}

.progress-dot[data-status="locked"] {
  opacity: 0.5;
}
```

## State Management in JavaScript

**Important**: Element references are stored during construction - NEVER use querySelector.

```javascript
class InteractiveComponent extends HTMLElement {
  // Direct element reference - created in constructor, never queried
  #button;

  static get observedAttributes() {
    return ['disabled', 'loading', 'selected'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    // Create and store direct reference during construction
    this.#button = document.createElement('button');
    this.#button.setAttribute('part', 'button');
    this.#button.appendChild(document.createElement('slot'));

    this.shadowRoot.appendChild(this.#button);
  }

  connectedCallback() {
    this.addEventListener('click', this);
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }

  handleEvent(e) {
    if (e.type === 'click') {
      this.dispatchEvent(new CustomEvent('button-click', {
        bubbles: true,
        composed: true
      }));
    }
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;

    switch (name) {
      case 'disabled':
        this.#updateDisabled(newVal !== null);
        break;
      case 'loading':
        this.#updateLoading(newVal !== null);
        break;
      case 'selected':
        this.#updateSelected(newVal !== null);
        break;
    }
  }

  #updateDisabled(disabled) {
    this.setAttribute('aria-disabled', disabled);
    this.#button.disabled = disabled;  // Direct reference
  }

  #updateLoading(loading) {
    this.setAttribute('aria-busy', loading);
    this.#button.disabled = loading;   // Direct reference
  }

  #updateSelected(selected) {
    this.setAttribute('aria-pressed', selected);
  }
}

customElements.define('interactive-button', InteractiveComponent);
```

Quick Install

$npx ai-builder add skill matthewharwood/ux-component-states

Details

Type
skill
Slug
matthewharwood/ux-component-states
Created
4d ago