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 issueswhen(isOpen, ...)conditionally renders the portalsignal(false)controls open/close state from anywhere- Keyboard: Escape closes,
aria-modalandrole="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.jsontypescript// 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
Props as an object — use a single
propsparameter with destructuring. TypeScript gives you autocomplete and validation for free.Signals for reactive props — pass
Signal<T>for values the parent controls. PassTfor static values.Return DOM nodes — components return
DocumentFragmentorHTMLElement. Keep it simple.CSS classes, not inline styles — the examples above use inline styles for clarity. In production, use CSS classes and CSS custom properties for theming.
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
matchMediawith signals if needed.