Custom Components
Overview
GlyphJS ships with a rich library of built-in components for callouts, charts, tables, diagrams, and more. However, you may need custom components for domain-specific visualizations, branded UI elements, or specialized interactions that are not covered by the defaults.
Custom components integrate seamlessly into the Glyph pipeline:
- Authors write
ui:yourcomponentfenced code blocks in Markdown with YAML data - The compiler validates the YAML against your Zod schema
- The runtime renders your React component with the validated data
This guide walks through creating a complete custom component from scratch.
When to Create a Custom Component
Create a custom component when you need:
- Domain-specific visualizations — metric dashboards, industry-specific diagrams, custom chart types
- Branded elements — company-specific callouts, themed cards, custom navigation
- Interactive widgets — surveys, configurators, specialized forms
- Integration components — embed third-party libraries or services
Before building a custom component, check if an existing component can be configured or styled to meet your needs. The built-in components are highly themeable via CSS custom properties.
Schema Definition
Every Glyph component starts with a Zod schema that defines the shape of data authors write in YAML. The schema serves multiple purposes:
- Validation — The compiler rejects malformed data with clear error messages
- TypeScript types — Infer types directly from the schema for type-safe components
- Documentation — The schema is the source of truth for what fields are available
Creating a Schema
Here is a schema for a simple “metric card” component that displays a label, value, and optional trend indicator:
import { z } from 'zod';
export const metricCardSchema = z.object({ // Required fields label: z.string().describe('Display label for the metric'), value: z.union([z.string(), z.number()]).describe('The metric value'),
// Optional fields with defaults trend: z.enum(['up', 'down', 'neutral']).optional(), unit: z.string().optional(), precision: z.number().int().min(0).max(6).default(0),});
// Infer TypeScript type from the schemaexport type MetricCardData = z.infer<typeof metricCardSchema>;Schema Best Practices
Required vs Optional Fields
- Mark fields as required only if the component cannot render without them
- Use
.optional()for fields with sensible defaults - Use
.default()to specify default values that will be applied during parsing
z.object({ title: z.string(), // Required subtitle: z.string().optional(), // Optional, may be undefined maxItems: z.number().default(10), // Optional with default});Validation Rules
Add constraints to catch authoring errors early:
z.object({ // Constrained string id: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
// Number ranges percentage: z.number().min(0).max(100),
// Enum for known values size: z.enum(['sm', 'md', 'lg']),
// Union types value: z.union([z.string(), z.number()]),
// Arrays with constraints tags: z.array(z.string()).min(1).max(10),
// Nested objects author: z.object({ name: z.string(), avatar: z.string().url().optional(), }).optional(),});Descriptions
Add .describe() calls to document fields. These descriptions can be extracted for documentation generation:
z.object({ label: z.string().describe('Display label shown above the value'), value: z.number().describe('Numeric value to display'),});Component Implementation
With the schema defined, implement the React component that renders the data.
Props Interface
All Glyph components receive props via the GlyphComponentProps<T> interface:
interface GlyphComponentProps<T> { data: T; // Validated data from the schema block: Block; // Block metadata (id, type, source position) outgoingRefs: Reference[]; // References from this block to others incomingRefs: Reference[]; // References from other blocks to this one onNavigate: (ref: Reference) => void; // Navigate to a reference onInteraction?: (event) => void; // Emit interaction events theme: GlyphThemeContext; // Theme information layout: LayoutHints; // Layout hints from the IR container: ContainerContext; // Container measurement context}Most components only need data and block. Destructure only what you use.
Basic Component
Here is the MetricCard component implementation:
import type { ReactElement } from 'react';import type { GlyphComponentProps } from '@glyphjs/types';import type { MetricCardData } from './metric-card-schema';
const TREND_ICONS: Record<string, string> = { up: '\u2191', // Up arrow down: '\u2193', // Down arrow neutral: '\u2192', // Right arrow};
const TREND_COLORS: Record<string, string> = { up: 'var(--glyph-success, #22c55e)', down: 'var(--glyph-error, #ef4444)', neutral: 'var(--glyph-text-muted, #64748b)',};
export function MetricCard({ data, block,}: GlyphComponentProps<MetricCardData>): ReactElement { const { label, value, trend, unit, precision } = data;
// Format numeric values with precision const displayValue = typeof value === 'number' ? value.toFixed(precision) : value;
// Generate unique ID for accessibility const labelId = `glyph-metric-${block.id}-label`;
return ( <div role="figure" aria-labelledby={labelId} style={{ fontFamily: 'var(--glyph-font-body, inherit)', color: 'var(--glyph-text, #1a2035)', backgroundColor: 'var(--glyph-surface, #ffffff)', border: '1px solid var(--glyph-border, #d0d8e4)', borderRadius: 'var(--glyph-radius-md, 0.5rem)', padding: 'var(--glyph-spacing-md, 1rem)', display: 'inline-block', minWidth: '150px', }} > <div id={labelId} style={{ fontSize: '0.875em', color: 'var(--glyph-text-muted, #64748b)', marginBottom: 'var(--glyph-spacing-xs, 0.25rem)', }} > {label} </div>
<div style={{ fontSize: '1.5em', fontWeight: 700, display: 'flex', alignItems: 'baseline', gap: 'var(--glyph-spacing-xs, 0.25rem)', }} > <span>{displayValue}</span> {unit && ( <span style={{ fontSize: '0.6em', fontWeight: 400 }}> {unit} </span> )} {trend && ( <span style={{ fontSize: '0.75em', color: TREND_COLORS[trend], marginLeft: 'var(--glyph-spacing-xs, 0.25rem)', }} aria-label={`Trend: ${trend}`} > {TREND_ICONS[trend]} </span> )} </div> </div> );}Theming with CSS Variables
All styling should use CSS custom properties (var(--glyph-*, fallback)) for theming:
// Good - uses CSS variables with fallbacksstyle={{ color: 'var(--glyph-text, #1a2035)', backgroundColor: 'var(--glyph-surface, #ffffff)', borderRadius: 'var(--glyph-radius-md, 0.5rem)',}}
// Bad - hardcoded colorsstyle={{ color: '#1a2035', backgroundColor: '#ffffff',}}Key theming rules:
- Always provide fallbacks — The second argument to
var()ensures the component renders correctly even without a theme wrapper - Use global variables first — Check existing variables (
--glyph-text,--glyph-bg,--glyph-border, etc.) before creating component-specific ones - Never use
theme.isDark— CSS variables automatically adapt to light/dark themes
Available global CSS variables include:
| Category | Variables |
|---|---|
| Colors | --glyph-bg, --glyph-text, --glyph-text-muted, --glyph-border, --glyph-surface |
| Accent | --glyph-accent, --glyph-accent-hover, --glyph-accent-subtle |
| Status | --glyph-success, --glyph-warning, --glyph-error, --glyph-info |
| Spacing | --glyph-spacing-xs, --glyph-spacing-sm, --glyph-spacing-md, --glyph-spacing-lg |
| Typography | --glyph-font-body, --glyph-font-mono, --glyph-font-heading |
| Radius | --glyph-radius-sm, --glyph-radius-md, --glyph-radius-lg |
| Effects | --glyph-shadow-sm, --glyph-shadow-md, --glyph-transition |
Handling Interactions
For interactive components, use the onInteraction callback to emit events:
import type { GlyphComponentProps } from '@glyphjs/types';
export function InteractiveWidget({ data, block, onInteraction,}: GlyphComponentProps<WidgetData>): ReactElement { const handleClick = (itemId: string) => { onInteraction?.({ kind: 'custom', blockId: block.id, blockType: block.type, payload: { action: 'item-selected', itemId, }, }); };
return ( <div> {data.items.map((item) => ( <button key={item.id} onClick={() => handleClick(item.id)} > {item.label} </button> ))} </div> );}The onInteraction callback is only available when the runtime is configured with an interaction handler. Always check if it exists before calling (onInteraction?.(...)).
Accessibility
Follow these accessibility guidelines:
- Use semantic HTML — Choose appropriate elements (
button,nav,article, etc.) - Generate unique IDs — Use
block.idas a prefix:glyph-${componentName}-${block.id} - Add ARIA attributes — Include
role,aria-label,aria-labelledbyas needed - Support keyboard navigation — Interactive elements should be keyboard-accessible
// Example: accessible expandable section<div role="region" aria-labelledby={`glyph-section-${block.id}-title`}> <button id={`glyph-section-${block.id}-title`} aria-expanded={isOpen} aria-controls={`glyph-section-${block.id}-content`} onClick={() => setIsOpen(!isOpen)} > {title} </button> <div id={`glyph-section-${block.id}-content`} hidden={!isOpen} > {content} </div></div>Component Definition
The component definition connects the schema and renderer, and registers metadata about the component.
import type { GlyphComponentDefinition } from '@glyphjs/types';import { metricCardSchema } from './metric-card-schema';import { MetricCard } from './MetricCard';import type { MetricCardData } from './metric-card-schema';
export const metricCardDefinition: GlyphComponentDefinition<MetricCardData> = { // Block type (matches ui:metric-card in Markdown) type: 'ui:metric-card',
// Zod schema for validation schema: metricCardSchema,
// React component to render render: MetricCard,
// Optional: default theme variables for this component themeDefaults: { '--glyph-metric-card-value-size': '1.5rem', },
// Optional: declare dependencies on other block types dependencies: [],};
// Re-export for consumersexport { MetricCard };export type { MetricCardData };Definition Fields
| Field | Required | Description |
|---|---|---|
type | Yes | Block type identifier, must start with ui: |
schema | Yes | Zod schema with parse() and safeParse() methods |
render | Yes | React component that receives GlyphComponentProps<T> |
themeDefaults | No | Default CSS variable values for the component |
dependencies | No | Array of other block types this component depends on |
Registration
Register your component with the runtime to make it available for rendering.
Using createGlyphRuntime
The simplest approach is to include your component in the components array when creating the runtime:
import { createGlyphRuntime } from '@glyphjs/runtime';import { defaultComponents } from '@glyphjs/components';import { metricCardDefinition } from './metric-card';
const runtime = createGlyphRuntime({ components: [ ...defaultComponents, metricCardDefinition, ], theme: 'light',});
// Now ui:metric-card blocks will render using your componentroot.render(<runtime.GlyphDocument ir={ir} />);Dynamic Registration
For plugins or lazy-loaded components, register dynamically after runtime creation:
const runtime = createGlyphRuntime({ components: defaultComponents, theme: 'light',});
// Register laterruntime.registerComponent(metricCardDefinition);Using the Registry Directly
For advanced use cases, you can work with the PluginRegistry directly:
import { PluginRegistry } from '@glyphjs/runtime';
const registry = new PluginRegistry();
// Register a single componentregistry.registerComponent(metricCardDefinition);
// Register multiple componentsregistry.registerAll([ metricCardDefinition, anotherDefinition,]);
// Check if a component is registeredif (registry.has('ui:metric-card')) { const definition = registry.getRenderer('ui:metric-card');}
// Get all registered typesconst types = registry.getRegisteredTypes();// ['ui:callout', 'ui:metric-card', ...]Testing
Thorough testing ensures your component works correctly across different data, themes, and interactions.
Unit Testing with React Testing Library
Create unit tests for your component:
import { render, screen } from '@testing-library/react';import '@testing-library/jest-dom';import { MetricCard } from './MetricCard';import type { MetricCardData } from './metric-card-schema';import type { GlyphComponentProps, Block } from '@glyphjs/types';
// Helper to create mock propsfunction createMockProps<T>( data: T, blockType: string,): GlyphComponentProps<T> { const block: Block = { id: 'test-block-1', type: blockType as Block['type'], data, };
return { data, block, outgoingRefs: [], incomingRefs: [], onNavigate: () => {}, onInteraction: undefined, theme: { isDark: false, resolveVar: (v) => v }, layout: {}, container: { tier: 'md', width: 800 }, };}
describe('MetricCard', () => { it('renders label and value', () => { const props = createMockProps<MetricCardData>( { label: 'Revenue', value: 50000, precision: 0 }, 'ui:metric-card', );
render(<MetricCard {...props} />);
expect(screen.getByText('Revenue')).toBeInTheDocument(); expect(screen.getByText('50000')).toBeInTheDocument(); });
it('displays unit when provided', () => { const props = createMockProps<MetricCardData>( { label: 'Growth', value: 12.5, unit: '%', precision: 1 }, 'ui:metric-card', );
render(<MetricCard {...props} />);
expect(screen.getByText('%')).toBeInTheDocument(); });
it('shows trend indicator', () => { const props = createMockProps<MetricCardData>( { label: 'Users', value: 1000, trend: 'up', precision: 0 }, 'ui:metric-card', );
render(<MetricCard {...props} />);
expect(screen.getByLabelText('Trend: up')).toBeInTheDocument(); });
it('has correct accessibility attributes', () => { const props = createMockProps<MetricCardData>( { label: 'Score', value: 85, precision: 0 }, 'ui:metric-card', );
render(<MetricCard {...props} />);
const figure = screen.getByRole('figure'); expect(figure).toHaveAttribute('aria-labelledby'); });
it('formats numbers with precision', () => { const props = createMockProps<MetricCardData>( { label: 'Rate', value: 0.12345, precision: 2 }, 'ui:metric-card', );
render(<MetricCard {...props} />);
expect(screen.getByText('0.12')).toBeInTheDocument(); });});Storybook Stories
Create Storybook stories to visualize all component variants:
import type { Meta, StoryObj } from '@storybook/react';import { MetricCard } from './MetricCard';import type { MetricCardData } from './metric-card-schema';import type { GlyphComponentProps, Block } from '@glyphjs/types';
// Helper to create story propsfunction mockProps<T>( data: T, overrides?: { block?: Partial<Block> },): GlyphComponentProps<T> { const block: Block = { id: overrides?.block?.id ?? 'story-block', type: (overrides?.block?.type ?? 'ui:metric-card') as Block['type'], data, };
return { data, block, outgoingRefs: [], incomingRefs: [], onNavigate: () => {}, onInteraction: undefined, theme: { isDark: false, resolveVar: (v) => v }, layout: {}, container: { tier: 'md', width: 800 }, };}
const meta: Meta<typeof MetricCard> = { component: MetricCard, title: 'Components/MetricCard', parameters: { layout: 'centered', },};
export default meta;type Story = StoryObj<typeof MetricCard>;
export const Default: Story = { args: mockProps<MetricCardData>({ label: 'Monthly Revenue', value: 52400, precision: 0, }),};
export const WithUnit: Story = { args: mockProps<MetricCardData>({ label: 'Conversion Rate', value: 3.24, unit: '%', precision: 2, }),};
export const TrendUp: Story = { args: mockProps<MetricCardData>({ label: 'Active Users', value: 12543, trend: 'up', precision: 0, }),};
export const TrendDown: Story = { args: mockProps<MetricCardData>({ label: 'Bounce Rate', value: 45.2, unit: '%', trend: 'down', precision: 1, }),};
export const StringValue: Story = { args: mockProps<MetricCardData>({ label: 'Status', value: 'Healthy', trend: 'neutral', precision: 0, }),};Accessibility Testing
Use the Storybook a11y addon or run axe-core tests:
import { axe, toHaveNoViolations } from 'jest-axe';import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => { const props = createMockProps<MetricCardData>( { label: 'Test', value: 100, precision: 0 }, 'ui:metric-card', );
const { container } = render(<MetricCard {...props} />); const results = await axe(container);
expect(results).toHaveNoViolations();});Complete Example
Let us walk through creating a complete “Progress Bar” component from start to finish.
Step 1: Define the Schema
import { z } from 'zod';
export const progressBarSchema = z.object({ label: z.string().describe('Accessible label for the progress bar'), value: z.number().min(0).max(100).describe('Current progress (0-100)'), showValue: z.boolean().default(true).describe('Show percentage text'), color: z.enum(['primary', 'success', 'warning', 'error']).default('primary'),});
export type ProgressBarData = z.infer<typeof progressBarSchema>;Step 2: Implement the Component
import type { ReactElement } from 'react';import type { GlyphComponentProps } from '@glyphjs/types';import type { ProgressBarData } from './schema';
const COLOR_MAP: Record<string, string> = { primary: 'var(--glyph-accent, #3b82f6)', success: 'var(--glyph-success, #22c55e)', warning: 'var(--glyph-warning, #f59e0b)', error: 'var(--glyph-error, #ef4444)',};
export function ProgressBar({ data, block,}: GlyphComponentProps<ProgressBarData>): ReactElement { const { label, value, showValue, color } = data; const barId = `glyph-progress-${block.id}`; const labelId = `${barId}-label`;
return ( <div style={{ fontFamily: 'var(--glyph-font-body, inherit)', width: '100%', }} > <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--glyph-spacing-xs, 0.25rem)', fontSize: '0.875em', color: 'var(--glyph-text, #1a2035)', }} > <span id={labelId}>{label}</span> {showValue && <span>{value}%</span>} </div>
<div role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100} aria-labelledby={labelId} style={{ height: '8px', backgroundColor: 'var(--glyph-border, #e2e8f0)', borderRadius: 'var(--glyph-radius-sm, 0.25rem)', overflow: 'hidden', }} > <div style={{ height: '100%', width: `${value}%`, backgroundColor: COLOR_MAP[color], borderRadius: 'var(--glyph-radius-sm, 0.25rem)', transition: 'width 0.3s ease', }} /> </div> </div> );}Step 3: Create the Definition
import type { GlyphComponentDefinition } from '@glyphjs/types';import { progressBarSchema, type ProgressBarData } from './schema';import { ProgressBar } from './ProgressBar';
export const progressBarDefinition: GlyphComponentDefinition<ProgressBarData> = { type: 'ui:progress-bar', schema: progressBarSchema, render: ProgressBar,};
export { ProgressBar };export type { ProgressBarData };Step 4: Register and Use
import { compile } from '@glyphjs/compiler';import { createGlyphRuntime } from '@glyphjs/runtime';import { defaultComponents } from '@glyphjs/components';import { progressBarDefinition } from './progress-bar';
const markdown = `# Project Status
Current progress on the migration:
\`\`\`ui:progress-barlabel: Database Migrationvalue: 75color: primary\`\`\`
\`\`\`ui:progress-barlabel: API Updatesvalue: 100color: successshowValue: true\`\`\`
\`\`\`ui:progress-barlabel: Documentationvalue: 30color: warning\`\`\``;
const { ir } = compile(markdown);
const runtime = createGlyphRuntime({ components: [...defaultComponents, progressBarDefinition], theme: 'light',});
root.render(<runtime.GlyphDocument ir={ir} />);Step 5: Write Tests
import { render, screen } from '@testing-library/react';import '@testing-library/jest-dom';import { ProgressBar } from './ProgressBar';
describe('ProgressBar', () => { const mockProps = (data: Partial<ProgressBarData>) => ({ data: { label: 'Test', value: 50, showValue: true, color: 'primary' as const, ...data }, block: { id: 'test-1', type: 'ui:progress-bar' as const, data: {} }, outgoingRefs: [], incomingRefs: [], onNavigate: () => {}, theme: { isDark: false, resolveVar: (v: string) => v }, layout: {}, container: { tier: 'md' as const, width: 800 }, });
it('renders with correct aria attributes', () => { render(<ProgressBar {...mockProps({ value: 75 })} />);
const progressbar = screen.getByRole('progressbar'); expect(progressbar).toHaveAttribute('aria-valuenow', '75'); expect(progressbar).toHaveAttribute('aria-valuemin', '0'); expect(progressbar).toHaveAttribute('aria-valuemax', '100'); });
it('displays percentage when showValue is true', () => { render(<ProgressBar {...mockProps({ value: 42, showValue: true })} />); expect(screen.getByText('42%')).toBeInTheDocument(); });
it('hides percentage when showValue is false', () => { render(<ProgressBar {...mockProps({ value: 42, showValue: false })} />); expect(screen.queryByText('42%')).not.toBeInTheDocument(); });});Next Steps
Now that you know how to create custom components:
- Browse the built-in components for implementation patterns
- Learn about theming to customize colors and styles
- Explore the Plugin API for advanced registration options
- Check the IR Spec to understand block structure