Web Loom logo
Chapter 16Framework-Agnostic Patterns

Headless UI Behaviors

Chapter 16: Headless UI Behaviors

Every interactive application needs common UI behaviors: opening and closing dialogs, expanding and collapsing sections, navigating lists with keyboard arrows, managing form state, selecting items. These behaviors follow well-established patterns—dialogs have open/close states, disclosures toggle between expanded and collapsed, lists support single or multi-select with keyboard navigation.

Yet most developers implement these behaviors from scratch in each project, tightly coupled to their chosen framework. A React developer builds a dialog with useState and useEffect. A Vue developer uses ref and watch. An Angular developer creates a service with RxJS. Each implementation is framework-specific, non-reusable, and often incomplete (missing keyboard navigation, accessibility features, or edge case handling).

This chapter explores headless UI behaviors—a pattern that separates UI interaction logic from presentation. We'll examine why this separation matters for MVVM architecture, understand the core concept of headless components, then show how to implement atomic behaviors that work across all frameworks. We'll use ui-core from the Web Loom monorepo as a concrete example, but the patterns we discuss are transferable to any headless UI library—or even your own custom implementations.

The goal isn't to convince you to use a specific library. It's to teach you the pattern so you can recognize it in solutions like Radix UI, Headless UI, or Ark UI, and implement it yourself when needed.

The Headless UI Pattern

Let's start with a fundamental question: What is a UI component, really?

Most developers think of components as inseparable bundles of logic, styling, and markup. A "Button component" includes the click handler, the visual appearance, and the HTML structure. A "Dialog component" includes the open/close logic, the modal overlay styling, and the dialog markup.

But this bundling creates problems:

Problem 1: Framework lock-in. A React dialog component only works in React. Moving to Vue means rewriting everything, including the logic.

Problem 2: Styling constraints. Pre-styled components force you into their design system. Customizing them often means fighting against their CSS.

Problem 3: Limited reusability. The same dialog logic (open/close state, focus management, escape key handling) gets reimplemented in every project.

The headless UI pattern solves this by separating behavior from presentation:

Traditional Component = Behavior + Styling + Markup
Headless Component = Behavior only

A headless component provides the logic—state management, keyboard navigation, accessibility attributes—without dictating how it looks or what framework you use. You bring your own styling and markup. The component handles the complex interaction patterns; you control the visual design.

This separation is powerful because behavior is universal, but presentation is contextual. Dialog behavior (open/close state, focus trapping, escape key handling) works the same way in every application. But dialog appearance varies wildly—a settings dialog looks different from a confirmation dialog, which looks different from a full-screen modal.

By extracting behavior into framework-agnostic, headless components, you get:

  • Framework independence: The same behavior works in React, Vue, Angular, or vanilla JS
  • Complete styling control: No CSS to override, no design system to fight against
  • Reusability: Write the behavior once, use it everywhere
  • Testability: Test behavior in isolation without rendering UI
  • Composability: Combine atomic behaviors into complex patterns

Let's see this in practice.

Why Headless UI Matters for MVVM

In MVVM architecture, we already separate concerns into three layers:

  • Model: Domain logic and data
  • ViewModel: Presentation logic
  • View: UI rendering

Headless UI behaviors fit naturally into this architecture as reusable presentation logic. They sit between ViewModels and Views, providing common UI interaction patterns that ViewModels can leverage without coupling to specific frameworks.

Consider a greenhouse monitoring dashboard with a sensor configuration dialog. The traditional approach mixes everything together:

// ❌ Traditional approach: dialog logic mixed with component
function SensorConfigDialog({ sensor, onSave }) {
  const [isOpen, setIsOpen] = useState(false);
  const [formData, setFormData] = useState(sensor);
 
  // Dialog behavior mixed with component logic
  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape') setIsOpen(false);
    };
    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      return () => document.removeEventListener('keydown', handleEscape);
    }
  }, [isOpen]);
 
  const handleSave = () => {
    onSave(formData);
    setIsOpen(false);
  };
 
  return (
    <>
      <button onClick={() => setIsOpen(true)}>Configure Sensor</button>
      {isOpen && (
        <div className="modal-overlay" onClick={() => setIsOpen(false)}>
          <div className="modal-content" onClick={(e) => e.stopPropagation()}>
            <h2>Configure {sensor.name}</h2>
            {/* Form fields... */}
            <button onClick={handleSave}>Save</button>
            <button onClick={() => setIsOpen(false)}>Cancel</button>
          </div>
        </div>
      )}
    </>
  );
}

This works, but it's tightly coupled to React, mixes dialog behavior with sensor configuration logic, and will be reimplemented differently in Vue, Angular, and other frameworks.

The headless UI approach separates concerns:

// ✅ Headless approach: dialog behavior extracted
import { createDialogBehavior } from '@web-loom/ui-core';
 
// Dialog behavior is framework-agnostic
const dialogBehavior = createDialogBehavior({
  id: 'sensor-config-dialog',
  onOpen: (sensor) => console.log('Configuring sensor:', sensor),
  onClose: () => console.log('Dialog closed'),
});
 
// React component uses the behavior
function SensorConfigDialog({ sensor, onSave }) {
  const [isOpen, setIsOpen] = useState(false);
 
  useEffect(() => {
    return dialogBehavior.subscribe((state) => {
      setIsOpen(state.isOpen);
    });
  }, []);
 
  const handleSave = (formData) => {
    onSave(formData);
    dialogBehavior.actions.close();
  };
 
  return (
    <>
      <button onClick={() => dialogBehavior.actions.open(sensor)}>
        Configure Sensor
      </button>
      {isOpen && (
        <SensorConfigForm
          sensor={sensor}
          onSave={handleSave}
          onCancel={() => dialogBehavior.actions.close()}
        />
      )}
    </>
  );
}

Now the dialog behavior is extracted, framework-agnostic, and reusable. The same dialogBehavior can be used in Vue, Angular, or vanilla JavaScript with different UI implementations.

Atomic Behaviors: The Building Blocks

Headless UI libraries typically provide atomic behaviors—small, focused components that handle a single interaction pattern. These behaviors are the building blocks that compose into larger, more complex patterns.

Let's explore five fundamental atomic behaviors from ui-core, understanding what problems they solve and how they work.

Dialog Behavior

Dialogs (modals, popups, overlays) are everywhere in modern UIs. The behavior is simple: open, close, toggle. But implementing it correctly requires handling:

  • Open/close state management
  • Content passing (what to show in the dialog)
  • Callbacks for lifecycle events
  • Optional dialog identification

Here's the complete dialog behavior from packages/ui-core/src/behaviors/dialog.ts:

import { createStore, type Store } from '@web-loom/store-core';
 
export interface DialogState {
  isOpen: boolean;
  content: any;
  id: string | null;
}
 
export interface DialogActions {
  open: (content: any) => void;
  close: () => void;
  toggle: (content?: any) => void;
}
 
export interface DialogBehaviorOptions {
  id?: string;
  onOpen?: (content: any) => void;
  onClose?: () => void;
}
 
export function createDialogBehavior(options?: DialogBehaviorOptions) {
  const initialState: DialogState = {
    isOpen: false,
    content: null,
    id: options?.id || null,
  };
 
  const store = createStore<DialogState, DialogActions>(
    initialState,
    (set, get, actions) => ({
      open: (content: any) => {
        set((state) => ({
          ...state,
          isOpen: true,
          content,
        }));
 
        if (options?.onOpen) {
          options.onOpen(content);
        }
      },
 
      close: () => {
        set((state) => ({
          ...state,
          isOpen: false,
          content: null,
        }));
 
        if (options?.onClose) {
          options.onClose();
        }
      },
 
      toggle: (content?: any) => {
        const currentState = get();
        if (currentState.isOpen) {
          actions.close();
        } else {
          actions.open(content !== undefined ? content : null);
        }
      },
    }),
  );
 
  return {
    getState: store.getState,
    subscribe: store.subscribe,
    actions: store.actions,
    destroy: store.destroy,
  };
}

Notice the pattern:

  1. State interface: Defines what data the behavior manages (isOpen, content, id)
  2. Actions interface: Defines what operations are available (open, close, toggle)
  3. Options interface: Defines configuration and callbacks
  4. Factory function: Creates an instance with the specified options
  5. Store-based implementation: Uses store-core for reactive state management

This pattern repeats across all atomic behaviors. The behavior is just state + actions + subscriptions—no UI, no framework dependencies.

Usage is straightforward:

// Create the behavior
const dialog = createDialogBehavior({
  id: 'settings-dialog',
  onOpen: (content) => console.log('Dialog opened with:', content),
  onClose: () => console.log('Dialog closed'),
});
 
// Open the dialog
dialog.actions.open({ title: 'Settings', tab: 'general' });
 
// Subscribe to changes
const unsubscribe = dialog.subscribe((state) => {
  console.log('Dialog state:', state);
  // Update UI based on state.isOpen
});
 
// Close the dialog
dialog.actions.close();
 
// Clean up
unsubscribe();
dialog.destroy();

The behavior is framework-agnostic. You can use it in React with useEffect, in Vue with watchEffect, in Angular with RxJS, or in vanilla JavaScript with direct subscriptions.

Disclosure Behavior

Disclosures control expandable/collapsible content—accordions, collapsible sections, dropdown menus. The behavior is similar to dialogs but uses isExpanded instead of isOpen:

// From packages/ui-core/src/behaviors/disclosure.ts
export interface DisclosureState {
  isExpanded: boolean;
  id: string | null;
}
 
export interface DisclosureActions {
  expand: () => void;
  collapse: () => void;
  toggle: () => void;
}
 
export function createDisclosureBehavior(options?: {
  id?: string;
  initialExpanded?: boolean;
  onExpand?: () => void;
  onCollapse?: () => void;
}) {
  const initialState: DisclosureState = {
    isExpanded: options?.initialExpanded ?? false,
    id: options?.id || null,
  };
 
  const store = createStore<DisclosureState, DisclosureActions>(
    initialState,
    (set, get) => ({
      expand: () => {
        const currentState = get();
        if (!currentState.isExpanded) {
          set((state) => ({ ...state, isExpanded: true }));
          if (options?.onExpand) options.onExpand();
        }
      },
 
      collapse: () => {
        const currentState = get();
        if (currentState.isExpanded) {
          set((state) => ({ ...state, isExpanded: false }));
          if (options?.onCollapse) options.onCollapse();
        }
      },
 
      toggle: () => {
        const currentState = get();
        if (currentState.isExpanded) {
          set((state) => ({ ...state, isExpanded: false }));
          if (options?.onCollapse) options.onCollapse();
        } else {
          set((state) => ({ ...state, isExpanded: true }));
          if (options?.onExpand) options.onExpand();
        }
      },
    }),
  );
 
  return {
    getState: store.getState,
    subscribe: store.subscribe,
    actions: store.actions,
    destroy: store.destroy,
  };
}

The pattern is identical to dialog behavior: state + actions + subscriptions. This consistency makes headless behaviors easy to learn and use.

Example usage for an FAQ accordion:

const faqDisclosure = createDisclosureBehavior({
  id: 'faq-section-1',
  initialExpanded: false,
  onToggle: (isExpanded) => console.log('Section expanded:', isExpanded),
});
 
// Toggle the disclosure
faqDisclosure.actions.toggle();
console.log(faqDisclosure.getState().isExpanded); // true
 
// Collapse explicitly
faqDisclosure.actions.collapse();
console.log(faqDisclosure.getState().isExpanded); // false

List Selection Behavior

List selection is more complex than dialogs or disclosures. It needs to handle:

  • Single selection (only one item selected at a time)
  • Multi-selection (multiple items selected independently)
  • Range selection (shift-click to select ranges)
  • Keyboard navigation
  • Selection state tracking

Here's the core interface from packages/ui-core/src/behaviors/list-selection.ts:

export type SelectionMode = 'single' | 'multi' | 'range';
 
export interface ListSelectionState {
  selectedIds: string[];
  lastSelectedId: string | null;
  mode: SelectionMode;
  items: string[];
}
 
export interface ListSelectionActions {
  select: (id: string) => void;
  deselect: (id: string) => void;
  toggleSelection: (id: string) => void;
  selectRange: (startId: string, endId: string) => void;
  clearSelection: () => void;
  selectAll: () => void;
}
 
export function createListSelection(options?: {
  items?: string[];
  initialSelectedIds?: string[];
  mode?: SelectionMode;
  onSelectionChange?: (selectedIds: string[]) => void;
}) {
  const items = options?.items || [];
  const initialSelectedIds = options?.initialSelectedIds || [];
  const mode = options?.mode || 'single';
 
  const initialState: ListSelectionState = {
    selectedIds: initialSelectedIds,
    lastSelectedId: initialSelectedIds.length > 0 
      ? initialSelectedIds[initialSelectedIds.length - 1] 
      : null,
    mode,
    items,
  };
 
  // Implementation handles single/multi/range selection logic...
  // See packages/ui-core/src/behaviors/list-selection.ts for full code
}

The behavior handles all the complex selection logic—mode switching, range calculations, state tracking—so you don't have to reimplement it in every list component.

Example usage for a sensor list:

// Single selection mode
const sensorSelection = createListSelection({
  items: ['sensor-1', 'sensor-2', 'sensor-3'],
  mode: 'single',
  onSelectionChange: (selectedIds) => {
    console.log('Selected sensors:', selectedIds);
  },
});
 
sensorSelection.actions.select('sensor-1');
console.log(sensorSelection.getState().selectedIds); // ['sensor-1']
 
sensorSelection.actions.select('sensor-2');
console.log(sensorSelection.getState().selectedIds); // ['sensor-2'] (sensor-1 deselected)
 
// Multi-selection mode
const multiSelection = createListSelection({
  items: ['sensor-1', 'sensor-2', 'sensor-3'],
  mode: 'multi',
});
 
multiSelection.actions.select('sensor-1');
multiSelection.actions.select('sensor-2');
console.log(multiSelection.getState().selectedIds); // ['sensor-1', 'sensor-2']
 
// Range selection mode
const rangeSelection = createListSelection({
  items: ['sensor-1', 'sensor-2', 'sensor-3', 'sensor-4'],
  mode: 'range',
});
 
rangeSelection.actions.selectRange('sensor-1', 'sensor-3');
console.log(rangeSelection.getState().selectedIds); // ['sensor-1', 'sensor-2', 'sensor-3']

Roving Focus Behavior

Roving focus implements keyboard navigation for composite widgets—toolbars, menus, tab lists, grids. It's the pattern where arrow keys move focus between items, Home/End jump to first/last, and only one item is tabbable at a time.

From packages/ui-core/src/behaviors/roving-focus.ts:

export interface RovingFocusState {
  currentIndex: number;
  previousIndex: number;
  items: string[];
  orientation: 'horizontal' | 'vertical';
  wrap: boolean;
}
 
export interface RovingFocusActions {
  moveNext: () => void;
  movePrevious: () => void;
  moveFirst: () => void;
  moveLast: () => void;
  moveTo: (index: number) => void;
  setItems: (items: string[]) => void;
}
 
export function createRovingFocus(options?: {
  items?: string[];
  initialIndex?: number;
  orientation?: 'horizontal' | 'vertical';
  wrap?: boolean;
  onFocusChange?: (index: number, itemId: string, previousIndex: number) => void;
}) {
  // Implementation handles focus movement with wrapping, orientation, etc.
  // See packages/ui-core/src/behaviors/roving-focus.ts for full code
}

Example usage for a toolbar:

const toolbar = createRovingFocus({
  items: ['button-1', 'button-2', 'button-3'],
  orientation: 'horizontal',
  wrap: true,
  onFocusChange: (index, itemId) => {
    console.log(`Focus moved to ${itemId} at index ${index}`);
    // Update UI to show focus
  },
});
 
// Move focus with arrow keys
toolbar.actions.moveNext();  // Focus moves to button-2
toolbar.actions.moveNext();  // Focus moves to button-3
toolbar.actions.moveNext();  // Focus wraps to button-1
 
// Jump to first/last
toolbar.actions.moveFirst(); // Focus moves to button-1
toolbar.actions.moveLast();  // Focus moves to button-3

Form Behavior

Forms are complex: they need field-level state, validation (sync and async), dirty tracking, touched tracking, submission handling, and error management. The form behavior handles all of this:

// From packages/ui-core/src/behaviors/form.ts
export interface FormState<T extends Record<string, any>> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  manualErrors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  dirty: Partial<Record<keyof T, boolean>>;
  isValidating: boolean;
  isValid: boolean;
  isSubmitting: boolean;
  submitCount: number;
}
 
export interface FormActions<T extends Record<string, any>> {
  setFieldValue: (field: keyof T, value: any) => void;
  setFieldTouched: (field: keyof T, touched: boolean) => void;
  setFieldError: (field: keyof T, error: string | null) => void;
  validateField: (field: keyof T) => Promise<void>;
  validateForm: () => Promise<boolean>;
  resetForm: () => void;
  submitForm: () => Promise<void>;
}
 
export function createFormBehavior<T extends Record<string, any>>(options: {
  initialValues: T;
  fields?: Partial<Record<keyof T, FieldConfig>>;
  validateOnChange?: boolean;
  validateOnBlur?: boolean;
  onSubmit?: (values: T) => void | Promise<void>;
  onValuesChange?: (values: T) => void;
}) {
  // Implementation handles validation, dirty tracking, submission...
  // See packages/ui-core/src/behaviors/form.ts for full code
}

Example usage for sensor configuration:

const sensorForm = createFormBehavior({
  initialValues: {
    name: '',
    type: 'temperature',
    threshold: 0,
  },
  fields: {
    name: {
      validate: (value) => {
        if (!value) return 'Name is required';
        if (value.length < 3) return 'Name must be at least 3 characters';
        return null;
      },
    },
    threshold: {
      validate: async (value) => {
        if (value < 0) return 'Threshold must be positive';
        // Could check against server constraints
        return null;
      },
    },
  },
  onSubmit: async (values) => {
    console.log('Saving sensor:', values);
    // Save to server
  },
});
 
// Set field value
sensorForm.actions.setFieldValue('name', 'Temperature Sensor 1');
 
// Mark field as touched (triggers validation if validateOnBlur is true)
sensorForm.actions.setFieldTouched('name', true);
 
// Set manual error (e.g., from server-side validation)
sensorForm.actions.setFieldError('name', 'Sensor name already exists');
 
// Submit form
await sensorForm.actions.submitForm();

Composing Behaviors into Patterns

Atomic behaviors are powerful on their own, but their real strength emerges when you compose them into larger patterns. A master-detail layout, for example, combines list selection with detail view synchronization. A wizard combines form behaviors with step navigation. A command palette combines list selection with keyboard shortcuts and filtering.

Let's see how behaviors compose using the master-detail pattern from packages/ui-patterns/src/patterns/master-detail.ts:

import { createStore } from '@web-loom/store-core';
import { createEventBus } from '@web-loom/event-bus-core';
import { createListSelection } from '@web-loom/ui-core';
 
export function createMasterDetail<T>(options: {
  items: T[];
  getId: (item: T) => string;
  initialDetailView?: string;
  onSelectionChange?: (selectedItem: T | null) => void;
}) {
  const items = options.items || [];
  const eventBus = createEventBus();
 
  // Create a map from item ID to item for quick lookup
  const itemMap = new Map<string, T>();
  items.forEach((item) => {
    const id = options.getId(item);
    itemMap.set(id, item);
  });
 
  // Compose list selection behavior
  const listSelection = createListSelection({
    items: items.map(options.getId),
    mode: 'single',
    onSelectionChange: (selectedIds) => {
      const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
      const selectedItem = selectedId ? itemMap.get(selectedId) || null : null;
 
      store.actions.updateSelectedItem(selectedItem);
 
      if (options.onSelectionChange) {
        options.onSelectionChange(selectedItem);
      }
 
      if (selectedItem) {
        eventBus.emit('item:selected', selectedItem);
      }
    },
  });
 
  // Create store for master-detail state
  const store = createStore(
    {
      items,
      selectedItem: null,
      detailView: options.initialDetailView || 'default',
    },
    (set) => ({
      selectItem: (item: T) => {
        const id = options.getId(item);
        listSelection.actions.select(id);
      },
 
      clearSelection: () => {
        listSelection.actions.clearSelection();
        set((state) => ({ ...state, selectedItem: null }));
        eventBus.emit('selection:cleared');
      },
 
      setDetailView: (view: string) => {
        set((state) => ({ ...state, detailView: view }));
      },
 
      updateSelectedItem: (item: T | null) => {
        set((state) => ({ ...state, selectedItem: item }));
      },
    }),
  );
 
  return {
    getState: store.getState,
    subscribe: store.subscribe,
    actions: {
      selectItem: store.actions.selectItem,
      clearSelection: store.actions.clearSelection,
      setDetailView: store.actions.setDetailView,
    },
    eventBus,
    destroy: () => {
      listSelection.destroy();
      store.destroy();
    },
  };
}

Notice how the pattern composes:

  1. List selection behavior handles item selection logic
  2. Store manages master-detail state (selected item, detail view)
  3. Event bus enables event-driven communication
  4. Synchronization keeps list selection and master-detail state in sync

The composed pattern is still framework-agnostic and headless. It provides the logic for master-detail layouts without dictating how they render.

Example usage for a sensor list with detail view:

interface Sensor {
  id: string;
  name: string;
  type: string;
  location: string;
}
 
const sensors: Sensor[] = [
  { id: '1', name: 'Temperature Sensor 1', type: 'temperature', location: 'Greenhouse A' },
  { id: '2', name: 'Humidity Sensor 1', type: 'humidity', location: 'Greenhouse A' },
  { id: '3', name: 'Soil Moisture Sensor 1', type: 'soil_moisture', location: 'Greenhouse B' },
];
 
const masterDetail = createMasterDetail({
  items: sensors,
  getId: (sensor) => sensor.id,
  onSelectionChange: (sensor) => {
    console.log('Selected sensor:', sensor);
  },
});
 
// Listen to events
masterDetail.eventBus.on('item:selected', (sensor) => {
  console.log('Item selected event:', sensor);
  // Load sensor details, readings, etc.
});
 
// Select a sensor
masterDetail.actions.selectItem(sensors[0]);
console.log(masterDetail.getState().selectedItem); // sensors[0]
 
// Switch detail view
masterDetail.actions.setDetailView('readings');
 
// Clear selection
masterDetail.actions.clearSelection();
console.log(masterDetail.getState().selectedItem); // null

This pattern can be used in any framework. In React, you'd subscribe with useEffect. In Vue, with watchEffect. In Angular, with RxJS. The behavior logic remains the same; only the framework integration changes.

Framework-Agnostic Benefits

Why go through the effort of extracting behaviors into framework-agnostic, headless components? What do you gain?

1. True Reusability

Write the behavior once, use it everywhere. The same dialog behavior works in:

  • React with hooks
  • Vue with Composition API
  • Angular with services
  • Lit with reactive controllers
  • Vanilla JavaScript with direct subscriptions

You're not maintaining five different implementations of dialog logic. You're maintaining one, and adapting it to each framework's integration pattern.

2. Testability

Headless behaviors are pure logic—no DOM, no rendering, no framework dependencies. This makes them trivial to test:

import { describe, it, expect } from 'vitest';
import { createDialogBehavior } from '@web-loom/ui-core';
 
describe('Dialog Behavior', () => {
  it('should open and close', () => {
    const dialog = createDialogBehavior();
 
    expect(dialog.getState().isOpen).toBe(false);
 
    dialog.actions.open({ title: 'Test' });
    expect(dialog.getState().isOpen).toBe(true);
    expect(dialog.getState().content).toEqual({ title: 'Test' });
 
    dialog.actions.close();
    expect(dialog.getState().isOpen).toBe(false);
    expect(dialog.getState().content).toBe(null);
  });
 
  it('should toggle state', () => {
    const dialog = createDialogBehavior();
 
    dialog.actions.toggle({ title: 'Test' });
    expect(dialog.getState().isOpen).toBe(true);
 
    dialog.actions.toggle();
    expect(dialog.getState().isOpen).toBe(false);
  });
 
  it('should invoke callbacks', () => {
    let openCalled = false;
    let closeCalled = false;
 
    const dialog = createDialogBehavior({
      onOpen: () => { openCalled = true; },
      onClose: () => { closeCalled = true; },
    });
 
    dialog.actions.open({ title: 'Test' });
    expect(openCalled).toBe(true);
 
    dialog.actions.close();
    expect(closeCalled).toBe(true);
  });
});

No mocking, no rendering, no framework setup. Just pure logic testing.

3. Separation of Concerns

Headless behaviors enforce clean separation between:

  • Behavior logic: State management, actions, validation
  • Presentation logic: How to render the UI
  • Styling: Visual appearance

This separation makes code easier to understand, maintain, and modify. You can change the UI without touching the behavior. You can change the behavior without touching the UI. You can test them independently.

4. Accessibility by Default

Complex UI patterns like roving focus, list selection, and keyboard shortcuts require careful implementation to be accessible. By extracting these patterns into reusable behaviors, you can implement accessibility once and get it everywhere.

For example, the roving focus behavior implements the ARIA roving tabindex pattern correctly. Any component using this behavior automatically gets proper keyboard navigation.

5. Composability

Atomic behaviors compose into larger patterns. You can build:

  • Master-detail from list selection + detail view synchronization
  • Wizard from form behaviors + step navigation
  • Command palette from list selection + keyboard shortcuts + filtering
  • Tabbed interface from disclosure behaviors + keyboard navigation
  • Modal from dialog behavior + focus trapping

Each composed pattern is still framework-agnostic and headless, inheriting the benefits of its atomic components.

Alternative Approaches

The headless UI pattern isn't the only way to build reusable UI behaviors. Let's examine alternatives and when you might choose them.

Approach 1: Framework-Specific Component Libraries

Libraries like Material-UI (React), Vuetify (Vue), or Angular Material provide complete, styled components. They bundle behavior, styling, and markup together.

Pros:

  • Fast to get started
  • Consistent design system out of the box
  • Well-tested, production-ready components

Cons:

  • Framework lock-in (can't reuse in other frameworks)
  • Styling constraints (customization often means fighting against the library)
  • Bundle size (you get all the CSS whether you need it or not)

When to use: Rapid prototyping, internal tools, or when you're committed to a single framework and design system.

Approach 2: Web Components

Web Components (Custom Elements, Shadow DOM) provide framework-agnostic components that work everywhere. Libraries like Lit, Stencil, or Shoelace use this approach.

Pros:

  • True framework independence (works in any framework or vanilla JS)
  • Encapsulation via Shadow DOM
  • Native browser support

Cons:

  • Shadow DOM styling challenges (CSS doesn't cross the boundary easily)
  • Still bundles behavior with presentation
  • Browser compatibility considerations

When to use: Building design systems that need to work across multiple frameworks, or when you need true encapsulation.

Approach 3: Render Props / Scoped Slots

React's render props pattern and Vue's scoped slots provide a way to share behavior while letting consumers control rendering.

Pros:

  • Flexible rendering (consumer controls the UI)
  • Behavior reuse within a framework
  • Type-safe (TypeScript support)

Cons:

  • Framework-specific (render props only work in React, scoped slots only in Vue)
  • Can lead to "wrapper hell" with deeply nested components
  • Not truly headless (still tied to component rendering)

When to use: Sharing behavior within a single framework when you don't need cross-framework support.

Approach 4: Headless UI Libraries

Libraries like Radix UI, Headless UI, Ark UI, and ui-core provide behavior without styling. They're the approach we've been discussing.

Pros:

  • Complete styling control
  • Framework-agnostic (or framework-specific but behavior-focused)
  • Composable atomic behaviors
  • Testable in isolation

Cons:

  • More setup required (you bring your own styling)
  • Steeper learning curve (need to understand the behavior API)
  • No visual consistency out of the box

When to use: Building custom design systems, when you need complete styling control, or when you need cross-framework behavior reuse.

Choosing an Approach

The right approach depends on your constraints:

  • Need speed? Use a framework-specific component library
  • Need cross-framework support? Use headless UI or Web Components
  • Need complete styling control? Use headless UI
  • Need encapsulation? Use Web Components
  • Building a design system? Use headless UI + your own styled components

For MVVM architecture, headless UI behaviors align best because they provide framework-agnostic presentation logic that ViewModels can leverage without coupling to specific frameworks.

Implementing Your Own Headless Behaviors

You don't need a library to use the headless UI pattern. Let's implement a simple headless behavior from scratch to understand the core concepts.

We'll build a simple tabs behavior that manages active tab state:

// Simple tabs behavior implementation
interface TabsState {
  activeTab: string;
  tabs: string[];
}
 
interface TabsActions {
  setActiveTab: (tabId: string) => void;
  nextTab: () => void;
  previousTab: () => void;
}
 
interface TabsBehaviorOptions {
  tabs: string[];
  initialTab?: string;
  onTabChange?: (tabId: string) => void;
}
 
function createTabsBehavior(options: TabsBehaviorOptions) {
  const tabs = options.tabs;
  const initialTab = options.initialTab || tabs[0];
 
  // State
  let state: TabsState = {
    activeTab: initialTab,
    tabs,
  };
 
  // Subscribers
  const subscribers = new Set<(state: TabsState) => void>();
 
  // Notify subscribers
  const notify = () => {
    subscribers.forEach((subscriber) => subscriber(state));
  };
 
  // Actions
  const actions: TabsActions = {
    setActiveTab: (tabId: string) => {
      if (tabs.includes(tabId)) {
        state = { ...state, activeTab: tabId };
        notify();
        if (options.onTabChange) {
          options.onTabChange(tabId);
        }
      }
    },
 
    nextTab: () => {
      const currentIndex = tabs.indexOf(state.activeTab);
      const nextIndex = (currentIndex + 1) % tabs.length;
      actions.setActiveTab(tabs[nextIndex]);
    },
 
    previousTab: () => {
      const currentIndex = tabs.indexOf(state.activeTab);
      const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      actions.setActiveTab(tabs[prevIndex]);
    },
  };
 
  return {
    getState: () => state,
    subscribe: (listener: (state: TabsState) => void) => {
      subscribers.add(listener);
      return () => subscribers.delete(listener);
    },
    actions,
    destroy: () => {
      subscribers.clear();
    },
  };
}

That's it! A complete headless tabs behavior in ~60 lines of code. The pattern is:

  1. Define state interface: What data does the behavior manage?
  2. Define actions interface: What operations are available?
  3. Define options interface: What configuration does it need?
  4. Implement state management: Store state, notify subscribers on changes
  5. Implement actions: Modify state, invoke callbacks
  6. Return behavior interface: getState, subscribe, actions, destroy

Usage is framework-agnostic:

// Create the behavior
const tabs = createTabsBehavior({
  tabs: ['overview', 'readings', 'alerts'],
  initialTab: 'overview',
  onTabChange: (tabId) => console.log('Tab changed to:', tabId),
});
 
// Subscribe to changes
const unsubscribe = tabs.subscribe((state) => {
  console.log('Active tab:', state.activeTab);
  // Update UI to show active tab
});
 
// Change tabs
tabs.actions.setActiveTab('readings');
tabs.actions.nextTab(); // Moves to 'alerts'
tabs.actions.previousTab(); // Moves back to 'readings'
 
// Clean up
unsubscribe();
tabs.destroy();

This pattern scales to any UI behavior. The complexity is in the behavior logic (validation, keyboard navigation, accessibility), not in the pattern itself.

Key Takeaways

Headless UI behaviors separate interaction logic from presentation, providing framework-agnostic, reusable components that work across React, Vue, Angular, and vanilla JavaScript.

Core concepts:

  1. Headless = behavior without presentation: Logic, state, and actions without styling or markup
  2. Atomic behaviors: Small, focused components (dialog, disclosure, list selection, roving focus, form)
  3. Composition: Atomic behaviors combine into larger patterns (master-detail, wizard, command palette)
  4. Framework-agnostic: Same behavior works in any framework with different integration patterns
  5. Testable: Pure logic without DOM dependencies makes testing trivial

Benefits for MVVM:

  • Behaviors provide reusable presentation logic that ViewModels can leverage
  • Clean separation between behavior logic, presentation logic, and UI rendering
  • Framework independence aligns with MVVM's goal of framework-agnostic business logic
  • Composability enables building complex UI patterns from simple building blocks

Pattern structure:

interface BehaviorState { /* ... */ }
interface BehaviorActions { /* ... */ }
interface BehaviorOptions { /* ... */ }
 
function createBehavior(options: BehaviorOptions) {
  // State management
  // Actions implementation
  // Subscription handling
  
  return {
    getState: () => state,
    subscribe: (listener) => { /* ... */ },
    actions: { /* ... */ },
    destroy: () => { /* ... */ },
  };
}

When to use:

  • Building custom design systems with complete styling control
  • Sharing UI behaviors across multiple frameworks
  • Implementing complex interaction patterns (keyboard navigation, accessibility)
  • Testing UI logic in isolation without rendering

Alternatives:

  • Framework-specific component libraries (Material-UI, Vuetify) for rapid prototyping
  • Web Components for encapsulated, framework-agnostic components with styling
  • Render props / scoped slots for behavior sharing within a single framework

The headless UI pattern isn't about any specific library—it's about recognizing that UI behavior is universal and can be extracted into framework-agnostic, reusable components. Whether you use ui-core, Radix UI, Headless UI, or build your own, the pattern remains the same: separate behavior from presentation, make it framework-agnostic, and compose atomic behaviors into larger patterns.

In the next chapter, we'll explore how these atomic behaviors compose into complete UI patterns like master-detail layouts, wizards, command palettes, and more, using ui-patterns as a concrete example.

Web Loom logo
Copyright © Web Loom. All rights reserved.