crefft Source 2026.10.4

Component Patterns

crefft > Design > Components

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.


Overview

Components are reusable building blocks that combine design tokens with behavior. Well-designed components are consistent, accessible, and composable.

Quick Reference

Component TypePurposeExamples
PrimitivesAtomic building blocksButton, Input, Badge
CompositesCombined primitivesCard, Modal, Dropdown
PatternsRecurring solutionsForm layout, Data table
TemplatesPage-level structuresDashboard, Settings page

Component Architecture

Composition Hierarchy

Components MUST be organized in increasing complexity:

Templates     <- Page layouts combining patterns
    |
Patterns      <- Solutions to common problems
    |
Composites    <- Combinations of primitives
    |
Primitives    <- Atomic, single-purpose components
    |
Tokens        <- Design values (color, space, type)

Primitive Requirements

Every primitive component MUST:

  1. Be single-purpose (do one thing well)
  2. Use design tokens exclusively
  3. Support all interaction states
  4. Be keyboard accessible
  5. Include ARIA attributes where needed
  6. Work in isolation (no external dependencies)

Buttons

Button Variants

Every project MUST define these button variants:

VariantUsageVisual Treatment
PrimaryMain action, one per viewSolid background, high contrast
SecondarySupporting actionsBorder or subtle background
GhostTertiary actions, tight spacesText only, background on hover
DangerDestructive actionsRed/error color scheme

Button Sizes

.btn {
  /* Base styles */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  font-weight: var(--font-medium);
  border-radius: var(--radius-lg);
  transition: all var(--duration-fast) var(--ease-out);
}

.btn-sm {
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
}

.btn-md {
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-sm);
}

.btn-lg {
  padding: var(--space-3) var(--space-6);
  font-size: var(--text-base);
}

Button States

Every button MUST define these states:

Button labels MUST retain at least 4.5:1 against their actual background in every theme and state, including disabled and loading states.

Why: A fixed white label fails on the dark theme’s light button fills. Reducing a disabled button’s opacity also reduces its text contrast; a cursor and the disabled state can signal unavailability without making its label faint.

.btn {
  /* Default state */
  background: var(--interactive-default);
  color: var(--text-on-fill);
}

.btn:hover {
  background: var(--interactive-hover);
  transform: translateY(-1px);
}

.btn:active {
  background: var(--interactive-active);
  transform: translateY(0);
}

.btn:focus-visible {
  outline: 2px solid var(--interactive-default);
  outline-offset: 2px;
}

.btn:disabled {
  cursor: not-allowed;
  transform: none;
}

Button Loading State

Buttons performing async actions MUST show loading state:

<button class="btn btn-primary" disabled aria-busy="true">
  <span class="spinner" aria-hidden="true"></span>
  <span>Saving...</span>
</button>

Button Accessibility

  • Buttons MUST have accessible labels (visible text or aria-label)
  • Icon-only buttons MUST have aria-label
  • Loading buttons MUST have aria-busy="true"
  • Disabled buttons SHOULD use aria-disabled for screen reader visibility

Form Inputs

Input Structure

<div class="form-field">
  <label for="email" class="form-label">
    Email address
    <span class="required" aria-hidden="true">*</span>
  </label>
  <input
    type="email"
    id="email"
    name="email"
    class="form-input"
    required
    aria-describedby="email-hint email-error"
  />
  <p id="email-hint" class="form-hint">We'll never share your email.</p>
  <p id="email-error" class="form-error" role="alert" hidden>
    Please enter a valid email address.
  </p>
</div>

Input States

.form-input {
  /* Default */
  width: 100%;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  color: var(--text-primary);
}

.form-input::placeholder {
  color: var(--text-muted);
}

.form-input:hover {
  border-color: var(--border-emphasis);
}

.form-input:focus-visible {
  border-color: var(--interactive-default);
  outline: 2px solid var(--interactive-default);
  outline-offset: 2px;
}

.form-input:disabled {
  background: var(--surface-inset);
  cursor: not-allowed;
}

/* Error state */
.form-input[aria-invalid="true"] {
  border-color: var(--status-error);
}

.form-input[aria-invalid="true"]:focus-visible {
  outline-color: var(--status-error);
}

Focus Indicators

Focus styling MUST be valid CSS, not merely intended CSS:

  • An outline MUST be declared before any decorative ring, so the indicator still exists if the ring is dropped
  • Translucent rings MUST use color-mix() or a dedicated ring token; alpha MUST NOT be appended inside var()
  • The focus indicator MUST reach 3:1 contrast against both the adjacent component color and the background it sits on
/* ✗ DON'T: invalid var() grammar. The declaration is discarded, so the
   input is left with `outline: none` and no focus indicator at all. */
.form-input:focus {
  outline: none;
  box-shadow: 0 0 0 3px var(--interactive-default / 0.15);
}

/* ✓ DO: a valid translucent ring on top of a self-sufficient outline */
.form-input:focus-visible {
  outline: 2px solid var(--interactive-default);
  outline-offset: 1px;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-default) 25%, transparent);
}

Why: var() accepts a custom property name followed only by an optional comma and fallback, so var(--interactive-default / 0.15) is a syntax error and Chromium reports CSS.supports() as false for it. The whole declaration is dropped while the sibling outline: none survives, which removes the focus indicator entirely — and for an invalid input the error border already matches the focus border, so nothing at all changes on focus. color-mix() has been Baseline widely available since 9 May 2023 and produces the alpha the token cannot carry. A solid 2px outline is also the simplest way to satisfy the minimum-area part of WCAG 2.2 Focus Appearance, and these tokens clear its 3:1 contrast requirement in both themes: #6366f1 on white measures 4.47:1, #818cf8 on the dark card #0a0a0a measures 6.64:1.

Form Validation

  • Validation errors MUST be associated via aria-describedby
  • Error messages MUST have role="alert" for screen reader announcement
  • Invalid inputs MUST have aria-invalid="true"
  • Error messages MUST explain how to fix the problem
.form-error {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  margin-top: var(--space-1);
  font-size: var(--text-xs);
  color: var(--status-error);
}

Cards

Card Structure

<article class="card">
  <header class="card-header">
    <h3 class="card-title">Card Title</h3>
    <p class="card-subtitle">Optional subtitle</p>
  </header>
  <div class="card-body">
    <!-- Card content -->
  </div>
  <footer class="card-footer">
    <!-- Actions -->
  </footer>
</article>

Card Variants

VariantUsage
DefaultStandard content container
InteractiveClickable cards (link/button)
ElevatedFloating UI, important content
BorderedSubtle separation without shadow

Interactive Cards

Clickable cards MUST:

  1. Have visible focus state
  2. Support keyboard activation (Enter/Space)
  3. Indicate interactivity on hover
.card-interactive {
  cursor: pointer;
  transition: transform var(--duration-fast) var(--ease-spring),
              box-shadow var(--duration-fast) var(--ease-out);
}

.card-interactive:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow-md);
}

.card-interactive:focus-visible {
  outline: 2px solid var(--interactive-default);
  outline-offset: 2px;
}

Modals and Dialogs

<dialog
  class="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
>
  <header class="modal-header">
    <h2 id="modal-title" class="modal-title">Modal Title</h2>
    <button class="modal-close" aria-label="Close modal">
      <svg aria-hidden="true"><!-- X icon --></svg>
    </button>
  </header>
  <div class="modal-body">
    <!-- Modal content -->
  </div>
  <footer class="modal-footer">
    <button class="btn btn-ghost">Cancel</button>
    <button class="btn btn-primary">Confirm</button>
  </footer>
</dialog>

A modal dialog MUST NOT ship its own backdrop element. showModal() renders the dialog in the top layer with a ::backdrop pseudo-element, which a separate <div> can only compete with:

.modal::backdrop {
  background: rgb(0 0 0 / 0.5);
}

Modals MUST:

  1. Use <dialog> opened with showModal()
  2. Keep focus inside the dialog while it is open
  3. Return focus to the invoking element on close
  4. Close on Escape key
  5. Have accessible title (aria-labelledby)
  6. Prevent background scroll, and restore whatever scroll state the page had

Why: showModal() puts the dialog in the top layer, marks everything else in the document inert, and handles Escape as a close request. Chromium confirms it: with a modal dialog open, repeated Tab presses cycle only through the dialog’s own controls, and Escape fires cancel then close. A hand-written focus trap and keydown listener therefore add nothing, and both are common sources of the lifecycle bugs below.

Focus Management

State that open() changes MUST be restored by the close event, not by the close() method, because Escape and a <form method="dialog"> submit close the dialog without going through it:

class Modal {
  constructor(dialog, { fallbackFocus = null } = {}) {
    this.dialog = dialog;
    this.fallbackFocus = fallbackFocus;
    this.invoker = null;
    this.previousOverflow = null;

    // Bound once. Reopening MUST NOT accumulate listeners, and every close
    // path — Escape, close(), method="dialog" — ends in this event.
    this.dialog.addEventListener('close', () => this.restore());
  }

  open(invoker = document.activeElement) {
    if (this.dialog.open) return;
    this.invoker = invoker;
    // `close` is dispatched asynchronously, so a reopen in the same task can
    // land before the previous restore. Only the first lock records state.
    if (this.previousOverflow === null) {
      this.previousOverflow = document.body.style.overflow;
    }
    document.body.style.overflow = 'hidden';
    this.dialog.showModal();
    this.initialFocus().focus();
  }

  close(returnValue) {
    this.dialog.close(returnValue);
  }

  restore() {
    if (this.dialog.open) return; // reopened before this close was delivered

    // Put back the page's own value, which may not have been the default.
    document.body.style.overflow = this.previousOverflow ?? '';
    this.previousOverflow = null;

    // The invoker may have been removed by the action the dialog performed.
    const target = this.invoker?.isConnected ? this.invoker : this.fallbackFocus;
    this.invoker = null;
    target?.focus();
  }

  initialFocus() {
    return (
      this.dialog.querySelector('[autofocus]') ??
      this.dialog.querySelector('.modal-body :is(a[href], button, input, select, textarea)') ??
      this.dialog
    );
  }
}

Confirming a destructive action before it happens belongs in cancel, which is cancellable, rather than in a keydown handler:

dialog.addEventListener('cancel', (event) => {
  if (form.hasUnsavedChanges) event.preventDefault();
});

The fallbackFocus element MUST itself be focusable — usually the heading of the region the dialog acted on, given tabindex="-1" — or focus silently falls back to <body>.

Why: each of these rules fixes a failure that headless Chromium reproduces in the naive version. Adding the keydown listener inside open() leaves two listeners after a second open, so one Escape runs close() twice. Setting document.body.style.overflow = '' on close discards a page that was already clip. Cleaning up in close() rather than on the close event leaves the body stuck at hidden when the dialog closes natively. Initial focus follows the APG dialog pattern: an explicit autofocus target, otherwise the first control in the body, otherwise the dialog itself, which showModal() makes focusable without a tabindex.


Tables

Data Table Structure

<div class="table-container" role="region" aria-label="Device inventory">
  <table class="table">
    <caption class="sr-only">List of all devices</caption>
    <thead>
      <tr>
        <th scope="col">Status</th>
        <th scope="col">Hostname</th>
        <th scope="col">Type</th>
        <th scope="col">
          <span class="sr-only">Actions</span>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Active</td>
        <td>blueridge</td>
        <td>Bare Metal</td>
        <td>
          <button aria-label="Actions for blueridge">⋮</button>
        </td>
      </tr>
    </tbody>
  </table>
</div>

Table Requirements

  • Tables MUST have <caption> (visible or sr-only)
  • Header cells MUST use <th> with scope="col" or scope="row"
  • Sortable columns MUST expose sort state with aria-sort on the <th>, never on the button inside it
  • No more than one header per table MUST carry aria-sort at a time
  • Action columns MUST have accessible labels
  • Responsive tables SHOULD scroll horizontally on small screens

Sortable Columns

The sort button is the control; the header cell holds the state:

<thead>
  <!-- ✓ DO: aria-sort on the sorted column header -->
  <tr>
    <th scope="col" aria-sort="ascending">
      <button type="button" class="table-sort">
        Hostname
        <span aria-hidden="true" class="sort-icon">▲</span>
      </button>
    </th>
    <th scope="col">
      <button type="button" class="table-sort">
        Type
        <span aria-hidden="true" class="sort-icon"></span>
      </button>
    </th>
    <th scope="col">Address</th>
  </tr>
</thead>
<!-- ✗ DON'T: aria-sort is not defined for the button role, so the header's
     sort state is never exposed -->
<th scope="col">
  <button class="table-sort" aria-sort="ascending">Hostname</button>
</th>

Sorting moves the attribute rather than adding a second one:

function setSortState(header, direction) {
  for (const th of header.closest('tr').querySelectorAll('th[aria-sort]')) {
    if (th === header) continue;
    th.removeAttribute('aria-sort');
    th.querySelector('.sort-icon').textContent = '';
  }
  header.setAttribute('aria-sort', direction);
  header.querySelector('.sort-icon').textContent =
    direction === 'ascending' ? '▲' : '▼';
}

function initSortableTable(table, sortRows) {
  for (const button of table.querySelectorAll('th .table-sort')) {
    button.addEventListener('click', () => {
      const header = button.closest('th');
      // An unsorted column starts ascending; the sorted column toggles.
      const direction =
        header.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
      sortRows(header.cellIndex, direction);
      setSortState(header, direction);
    });
  }
}

The sort buttons’ purpose SHOULD be described once in the caption rather than repeated in every button label:

<caption>
  Device inventory
  <span class="sr-only">Column headers with buttons sort the table.</span>
</caption>

Why: aria-sort is defined only for the columnheader and rowheader roles, which a nested <button> does not have, so on the button it says nothing about the column. MDN’s aria-sort reference and the APG sortable-table example both set the attribute on the currently sorted header, keep it on exactly one header at a time, and move it when another column is sorted. MDN also puts the sorting instructions in the caption rather than repeating them in every column label.


Toasts and Notifications

Toast Structure

<div class="toast-container" aria-live="polite" aria-atomic="true">
  <div class="toast toast-success" role="status">
    <svg class="toast-icon" aria-hidden="true"><!-- check --></svg>
    <p class="toast-message">Settings saved successfully</p>
    <button class="toast-close" aria-label="Dismiss">
      <svg aria-hidden="true"><!-- X --></svg>
    </button>
  </div>
</div>

Toast Variants

VariantUsageRole
SuccessCompleted actionsrole="status"
ErrorFailed actionsrole="alert"
WarningPotential issuesrole="status"
InfoNeutral informationrole="status"

Toast Requirements

  • Error toasts MUST use role="alert" for immediate announcement
  • Non-error toasts SHOULD use role="status"
  • Toasts MUST be dismissible
  • Toasts SHOULD auto-dismiss after 5-10 seconds (except errors)
  • Toast container MUST have aria-live="polite"

Empty States

Empty State Structure

<div class="empty-state">
  <div class="empty-illustration">
    <svg aria-hidden="true"><!-- illustration --></svg>
  </div>
  <h3 class="empty-title">No devices found</h3>
  <p class="empty-description">
    Try adjusting your filters or add a new device.
  </p>
  <div class="empty-actions">
    <button class="btn btn-ghost">Clear filters</button>
    <button class="btn btn-primary">Add device</button>
  </div>
</div>

Empty State Requirements

Every empty state MUST include:

  1. Clear explanation of why it’s empty
  2. Guidance on what to do next
  3. Action buttons when applicable

Empty states SHOULD NOT:

  • Use technical jargon
  • Blame the user
  • Leave users without next steps

Loading States

Skeleton Screens

Use skeleton screens for content-heavy loading:

<div class="skeleton-card">
  <div class="skeleton skeleton-avatar"></div>
  <div class="skeleton skeleton-line" style="width: 60%"></div>
  <div class="skeleton skeleton-line" style="width: 80%"></div>
  <div class="skeleton skeleton-line" style="width: 40%"></div>
</div>
.skeleton {
  background: linear-gradient(
    90deg,
    var(--surface-inset) 25%,
    var(--surface-elevated) 50%,
    var(--surface-inset) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: var(--radius-sm);
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Spinners

Use spinners for action-based loading:

<span class="spinner" aria-label="Loading"></span>
.spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--border-default);
  border-top-color: var(--interactive-default);
  border-radius: var(--radius-full);
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

Loading Requirements

  • Long operations (>1s) MUST show loading indicator
  • Loading states MUST be announced to screen readers
  • Loading MUST NOT block critical UI
  • Progress bars SHOULD show determinate progress when known

Component Documentation

Documentation Requirements

Every component MUST be documented with:

  1. Purpose: What the component does
  2. Variants: All visual variations
  3. States: All interaction states
  4. Props/API: Configuration options
  5. Accessibility: ARIA requirements and keyboard behavior
  6. Examples: Code snippets for common uses
  7. Do’s and Don’ts: Usage guidelines

Documentation Template

# Component Name

Brief description of what this component does.

## Usage

When to use this component.

## Variants

| Variant | Description |
|---------|-------------|
| Primary | Main usage |
| Secondary | Alternative usage |

## States

- Default
- Hover
- Active
- Focus
- Disabled
- Loading
- Error

## API

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| variant | string | 'primary' | Visual variant |
| disabled | boolean | false | Disabled state |

## Accessibility

- Keyboard behavior
- ARIA requirements
- Screen reader notes

## Examples

[Code examples]

## Do's and Don'ts

✓ Do: [Good practice]
✗ Don't: [Anti-pattern]

See Also