Component Patterns

This guide shows how to build real UI components with CellUI — the kind you'd find in shadcn, Chakra, or Carbon. CellUI doesn't ship pre-built components. Instead, you own the code. Every component here is copy-paste ready.

Button with Variants

The most common component. Supports variant, size, and disabled:

typescriptimport { signal, view } from '@cmj/cellui';

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

const variantStyles: Record<ButtonVariant, string> = {
  primary: 'background: var(--color-action); color: white; border-color: var(--color-action);',
  secondary: 'background: var(--color-surface); color: var(--color-text); border-color: var(--color-border);',
  ghost: 'background: transparent; color: var(--color-text-muted); border-color: transparent;',
  danger: 'background: var(--color-danger); color: white; border-color: var(--color-danger);',
};

const sizeStyles: Record<ButtonSize, string> = {
  sm: 'padding: 6px 12px; font-size: 13px;',
  md: 'padding: 10px 20px; font-size: 14px;',
  lg: 'padding: 14px 28px; font-size: 16px;',
};

function Button(props: {
  label: string;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  onclick?: () => void;
}) {
  const { label, variant = 'primary', size = 'md', disabled = false, onclick } = props;
  const style = `${variantStyles[variant]} ${sizeStyles[size]} border: 1px solid; border-radius: 8px; font-weight: 600; cursor: ${disabled ? 'not-allowed' : 'pointer'}; opacity: ${disabled ? '0.5' : '1'};`;

  return view`
    <button style="${style}" onclick="${disabled ? undefined : onclick}">${label}</button>
  `;
}

// Usage:
view`
  <div>
    ${Button({ label: 'Save', variant: 'primary', onclick: () => save() })}
    ${Button({ label: 'Cancel', variant: 'ghost', onclick: () => cancel() })}
    ${Button({ label: 'Delete', variant: 'danger', size: 'sm', onclick: () => remove() })}
    ${Button({ label: 'Disabled', disabled: true })}
  </div>
`;

Why this works in CellUI

The function runs once. The styles are computed once. If you want a reactive variant (e.g., button changes from "Save" to "Saving..."), use signals:

typescriptfunction LoadingButton(props: { loading: Signal<boolean>; onclick: () => void }) {
  return view`
    <button
      class="${() => props.loading.value ? 'btn btn-loading' : 'btn btn-primary'}"
      onclick="${() => { if (!props.loading.value) props.onclick(); }}"
    >
      ${() => props.loading.value ? 'Saving...' : 'Save'}
    </button>
  `;
}

Modal (Dialog)

Uses portal() for DOM escape, transition() for animation, and keyboard handling for accessibility:

typescriptimport { signal, view, when } from '@cmj/cellui';
import { portal } from '@cmj/cellui';

function Modal(props: {
  isOpen: Signal<boolean>;
  title: string;
  children: () => Node | DocumentFragment;
}) {
  const { isOpen, title, children } = props;

  const close = () => { isOpen.value = false; };

  const handleKeydown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') close();
  };

  return when(isOpen, {
    true: () => portal('body', () => {
      const overlay = document.createElement('div');
      overlay.className = 'modal-overlay';
      overlay.addEventListener('click', (e) => {
        if (e.target === overlay) close();
      });
      overlay.addEventListener('keydown', handleKeydown);

      const dialog = document.createElement('div');
      dialog.setAttribute('role', 'dialog');
      dialog.setAttribute('aria-modal', 'true');
      dialog.setAttribute('aria-label', title);
      dialog.className = 'modal-dialog';
      dialog.tabIndex = -1;

      dialog.appendChild(view`
        <div class="modal-header">
          <h2>${title}</h2>
          <button class="modal-close" onclick="${close}" aria-label="Close">✕</button>
        </div>
        <div class="modal-body">
          ${children()}
        </div>
      `);

      overlay.appendChild(dialog);

      // Focus trap + auto-focus
      requestAnimationFrame(() => dialog.focus());

      return overlay;
    }),
  });
}

// Usage:
const showModal = signal(false);

view`
  ${Button({ label: 'Open Modal', onclick: () => showModal.value = true })}
  ${Modal({
    isOpen: showModal,
    title: 'Confirm Action',
    children: () => view`
      <p>Are you sure you want to proceed?</p>
      <div style="display: flex; gap: 8px; margin-top: 16px;">
        ${Button({ label: 'Confirm', variant: 'primary', onclick: () => showModal.value = false })}
        ${Button({ label: 'Cancel', variant: 'ghost', onclick: () => showModal.value = false })}
      </div>
    `,
  })}
`;

CSS for the modal:

css.modal-overlay {
  position: fixed; inset: 0; z-index: 1000;
  background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
  display: flex; align-items: center; justify-content: center;
}
.modal-dialog {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: 12px; padding: 24px; max-width: 480px; width: 90%;
  outline: none;
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.modal-close { background: none; border: none; color: var(--color-text-muted); font-size: 18px; cursor: pointer; }

What this demonstrates

  • portal('body', ...) escapes the parent DOM tree — no z-index issues
  • when(isOpen, ...) conditionally renders the portal
  • signal(false) controls open/close state from anywhere
  • Keyboard: Escape closes, aria-modal and role="dialog" for screen readers
  • Click outside overlay closes
  • Focus is moved to the dialog on open

Tabs (Compound Component)

The pattern where parent and children share state:

typescriptimport { signal, view, when, each } from '@cmj/cellui';

interface Tab {
  id: string;
  label: string;
  content: () => Node | DocumentFragment;
}

function Tabs(tabs: Tab[], defaultTab?: string) {
  const activeId = signal(defaultTab || tabs[0]?.id || '');

  const tabList = view`
    <div role="tablist" class="tab-list">
      ${tabs.map(tab => view`
        <button
          role="tab"
          aria-selected="${() => activeId.value === tab.id}"
          aria-controls="panel-${tab.id}"
          class="${() => activeId.value === tab.id ? 'tab active' : 'tab'}"
          onclick="${() => activeId.value = tab.id}"
        >
          ${tab.label}
        </button>
      `)}
    </div>
  `;

  const panels = tabs.map(tab =>
    when(
      () => activeId.value === tab.id,
      () => {
        const panel = document.createElement('div');
        panel.setAttribute('role', 'tabpanel');
        panel.id = `panel-${tab.id}`;
        panel.className = 'tab-panel';
        panel.appendChild(tab.content());
        return panel;
      }
    )
  );

  const container = document.createElement('div');
  container.className = 'tabs';
  container.appendChild(tabList);
  panels.forEach(p => container.appendChild(p));
  return container;
}

// Usage:
Tabs([
  { id: 'code', label: 'Code', content: () => view`<pre>const x = 1;</pre>` },
  { id: 'preview', label: 'Preview', content: () => view`<div>Rendered output</div>` },
  { id: 'tests', label: 'Tests', content: () => view`<div>3 tests passing</div>` },
], 'code');

Why this works without a context API

In React, compound components need createContext to share state between <Tabs>, <Tab>, and <TabPanel>. In CellUI, the Tabs function owns the activeId signal and passes it to all children via closure. No context, no provider, no consumer. The signal IS the shared state.

Form Field (Composed)

A complete form field with label, input, error, and help text:

typescriptfunction FormField(props: {
  label: string;
  value: Signal<string>;
  error?: Signal<string>;
  help?: string;
  type?: string;
  required?: boolean;
}) {
  const { label, value, error, help, type = 'text', required = false } = props;
  const id = `field-${label.toLowerCase().replace(/\s+/g, '-')}`;

  return view`
    <div class="form-field">
      <label for="${id}" class="field-label">
        ${label}${required ? ' *' : ''}
      </label>
      <input
        id="${id}"
        type="${type}"
        class="${() => error?.value ? 'field-input field-error' : 'field-input'}"
        ${bind(value)}
        aria-describedby="${error ? `${id}-error` : help ? `${id}-help` : ''}"
        aria-invalid="${() => error?.value ? 'true' : 'false'}"
      />
      ${error ? when(computed(() => error.value !== ''), {
        true: () => view`<p id="${id}-error" class="field-error-text" role="alert">${error}</p>`,
      }) : ''}
      ${help ? view`<p id="${id}-help" class="field-help">${help}</p>` : ''}
    </div>
  `;
}

// Usage:
const email = signal('');
const emailError = signal('');

effect(() => {
  if (email.value && !email.value.includes('@')) {
    emailError.value = 'Enter a valid email address';
  } else {
    emailError.value = '';
  }
});

view`
  <form>
    ${FormField({ label: 'Email', value: email, error: emailError, type: 'email', required: true })}
    ${FormField({ label: 'Company', value: signal(''), help: 'Optional — for team plans' })}
  </form>
`;

Toast Notifications

A notification system using signals and portal():

typescriptinterface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }

const toasts = signal<Toast[]>([]);
let nextId = 0;

function addToast(message: string, type: Toast['type'] = 'info', duration = 4000) {
  const id = nextId++;
  toasts.value = [...toasts.value, { id, message, type }];
  setTimeout(() => {
    toasts.value = toasts.value.filter(t => t.id !== id);
  }, duration);
}

function ToastContainer() {
  return portal('body', () => {
    const container = document.createElement('div');
    container.className = 'toast-container';
    container.setAttribute('aria-live', 'polite');

    container.appendChild(
      each(toasts, 'id', (toast) => view`
        <div class="toast toast-${toast.type}" role="status">
          <span>${toast.message}</span>
          <button onclick="${() => toasts.value = toasts.value.filter(t => t.id !== toast.id)}" aria-label="Dismiss">✕</button>
        </div>
      `)
    );
    return container;
  });
}

// Mount once, use anywhere:
// view`${ToastContainer()}`
// addToast('File saved', 'success');
// addToast('Network error', 'error');

Building a Component Library

To share components across projects, create a package:

my-ui/
  src/
    button.ts
    modal.ts
    tabs.ts
    form-field.ts
    toast.ts
    tokens.ts      ← design tokens
    index.ts        ← re-exports
  package.json
typescript// my-ui/src/index.ts
export { Button } from './button';
export { Modal } from './modal';
export { Tabs } from './tabs';
export { FormField } from './form-field';
export { ToastContainer, addToast } from './toast';
export { tokens } from './tokens';
typescript// In your app:
import { Button, Modal, addToast } from 'my-ui';

CellUI components are plain functions that return DOM nodes. They don't need a framework-specific packaging format, no .vue files, no .svelte files. Export a function, import a function.

Patterns to Follow

  1. Props as an object — use a single props parameter with destructuring. TypeScript gives you autocomplete and validation for free.

  2. Signals for reactive props — pass Signal<T> for values the parent controls. Pass T for static values.

  3. Return DOM nodes — components return DocumentFragment or HTMLElement. Keep it simple.

  4. CSS classes, not inline styles — the examples above use inline styles for clarity. In production, use CSS classes and CSS custom properties for theming.

  5. ARIA from the start — add role, aria-* attributes when you write the component, not as an afterthought. The Modal and Tabs examples show this.

What CellUI Doesn't Provide

  • No pre-built component library — you build your own or wait for the community. This is intentional: you own the code, you control the bundle.
  • No style props system — no <Button color="blue" px={4}>. Use CSS classes or inline styles.
  • No automatic dark mode — use the theming guide with ambient() to build your own.
  • No responsive utilities — use CSS media queries and matchMedia with signals if needed.