react-ubiquitous
v1.3.0
Published
JSON-driven UI renderer — generate forms, pages, multi-step stages, carousels and list-detail views from pure config objects.
Maintainers
Readme
react-ubiquitous
The frontend is just a human-readable version of the API response.
Tailwind made CSS-limited developers build professional web pages. Shadcn made Tailwind-limited developers build professional web pages.
react-ubiquitousaims to make any API response a professional web page.
react-ubiquitous is a JSON-driven UI renderer for React. You describe your UI as a plain TypeScript config object — layouts, fields, pages, section types — and the library renders it. No bespoke components, no role checks in JSX, no deploy to change what a user sees. The backend controls what data arrives; react-ubiquitous controls how it looks.
Why react-ubiquitous?
The frontend has become too complicated
At some point, building a frontend stopped being about presenting information and started being about managing complexity that doesn't belong there.
Access control logic creeps into components. Role checks scatter across route guards, conditional renders, and hidden buttons. Theming systems grow into their own runtime. Feature flags, permission matrices, tenant overrides — all running in the browser, all making the frontend harder to reason about, test, and change.
Here's a simpler mental model:
The frontend is just a human-readable version of the API response.
If the backend returns a field, the user should see it. If they shouldn't see it, the backend shouldn't return it. Access control belongs on the server — not in a maze of if (user.role === 'admin') guards scattered across your JSX.
This has a liberating consequence: the frontend no longer needs to know about permissions. It just needs to know how to render what it receives. Any data that reaches the browser is, by definition, data the user is allowed to see.
The contract model
This philosophy creates a clean, enforceable contract:
- The frontend defines patterns — layouts, field types, section structures, interaction flows
- The backend adheres to those patterns — it returns data shaped to match the UI config
- When the two align, everything simply works — no bespoke rendering logic, no role-specific component trees, no deploy needed to change what a user sees
react-ubiquitous is built entirely around this contract. A stage config is just data. The renderer just renders it. What the backend sends, the user sees — shaped by the layout patterns you've defined once, reused everywhere.
Practically, this means
- No access control in the UI — serve different configs to different users from the backend
- No deploy to change a layout — update the JSON config, the UI updates automatically
- No per-tenant component forks — one renderer, many configs
- No bespoke forms — describe fields as data, let the renderer handle the rest
- No drift between design and code — the visual builder exports the exact config the renderer consumes
The companion visual builder (included in the demo) lets you design stages interactively and export the JSON config, closing the loop between designer, developer, and backend in one portable format.
Table of Contents
- Installation
- Quick Start
- How It Works
- Section Types
- Charts
- Element Types
- Validation
- Computed / Derived Fields (Formulas)
- Page-Transition Animations
- TypeScript Type Generation from Config
- onChange Callback
- Server-Side Validation
- Conditional Rendering
- ReadOnly Mode
- i18n / Validation Messages
- Form Persistence & Auto-Save
- Custom Component Injection
- Virtualization for Large Lists
- Theme Builder
- HTTP Networking Client
- Component API
- Storybook Component Catalogue
- TypeScript Reference
- Styling
- Publishing to npm
- Backend Integration
- Acknowledgements
- Changelog
Installation
npm install react-ubiquitousPeer dependencies — React ≥ 18 is required:
npm install react react-domQuick Start
import 'react-ubiquitous/styles.css' // ← required: CSS variables + Tailwind utilities
import { UIStage } from 'react-ubiquitous'
import type { UIStageConfig } from 'react-ubiquitous'
const config: UIStageConfig = {
id: 'sign-up',
title: 'Create Account',
pages: [
{
id: 'personal',
title: 'Personal Info',
order: 0,
sections: [
{
id: 'name-row',
layout: 'grid',
order: 0,
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '1rem',
elements: [
{
id: 'first',
name: 'firstName',
type: 'input',
inputType: 'text',
label: 'First name',
order: 0,
validations: [{ rule: 'required' }],
},
{
id: 'last',
name: 'lastName',
type: 'input',
inputType: 'text',
label: 'Last name',
order: 1,
validations: [{ rule: 'required' }],
},
],
},
],
},
],
}
export default function App() {
return (
<UIStage
config={config}
onChange={(name, value) => console.log(name, '=', value)}
/>
)
}How It Works
UIStageConfig
└─ UIPageConfig[] (rendered as a tab bar)
└─ UISectionConfig[] (flex | grid | hero | media | list-detail |
| tree-view | chat | navbar | sidebar |
| breadcrumbs | pagination | stepper | tabs | iframe)
└─ UIElementConfig[] (every HTML form control)Each layer is a plain TypeScript object discriminated by a single string field:
- Stage — top-level container with a title and a list of pages
- Page — a named tab; contains an ordered list of sections
- Section — a layout container; renders its elements in flex, grid, or a specialised display mode
- Element — a single interactive or display control (input, select, button, etc.)
Section Types
Every section shares a set of common fields:
| Field | Type | Description |
|---|---|---|
| id | string | Unique identifier |
| layout | string | Discriminant — see types below |
| title | string | Optional heading above the section |
| description | string | Optional subtitle |
| order | number | Sort order within the page |
| className | string | Extra CSS classes on the wrapper |
| style | CSSProperties | Inline styles on the wrapper |
| elements | UIElementConfig[] | Fields rendered inside the section |
Grid Section
Renders elements in a CSS grid.
{
id: 'details',
layout: 'grid',
order: 0,
gridTemplateColumns: 'repeat(3, 1fr)', // any CSS grid-template-columns value
gridTemplateRows: 'auto',
gap: '1rem',
rowGap: '0.5rem',
columnGap: '1.5rem',
alignItems: 'start', // 'start' | 'end' | 'center' | 'stretch'
justifyItems: 'stretch',
elements: [...],
}Flex Section
Renders elements in a CSS flexbox.
{
id: 'toolbar',
layout: 'flex',
order: 0,
flexDirection: 'row', // 'row' | 'column' | 'row-reverse' | 'column-reverse'
flexWrap: 'wrap', // 'nowrap' | 'wrap' | 'wrap-reverse'
justifyContent: 'flex-start', // all CSS justify-content values
alignItems: 'center',
gap: '0.75rem',
elements: [...],
}Hero Section
A full-width banner with a background (gradient, solid colour, or image URL), title, subtitle, description, and an optional CTA link.
{
id: 'banner',
layout: 'hero',
order: 0,
title: 'Welcome back',
subtitle: 'Here is what happened while you were away.',
description: 'Your dashboard shows the latest activity…',
// Background
backgroundType: 'gradient', // 'gradient' | 'color' | 'image'
gradientFrom: '#6366f1',
gradientTo: '#1e293b',
gradientDirection: 'to bottom right',
// backgroundType: 'color', backgroundColor: '#1e293b'
// backgroundType: 'image', backgroundImage: 'https://…'
// Optional dark overlay (useful over images)
overlay: true,
overlayOpacity: 40, // 0–100
// Layout
minHeight: '320px',
textAlign: 'center', // 'left' | 'center' | 'right'
verticalAlign: 'center', // 'top' | 'center' | 'bottom'
// CTA link
linkText: 'Learn more',
linkUrl: 'https://example.com',
linkRelative: false, // true → same tab, no rel attr
elements: [],
}Media Carousel Section
A responsive, accessible image/video carousel. Items can be images, videos, or a mix. Supports aspect-ratio control, arrow buttons, and dot navigation.
{
id: 'gallery',
layout: 'media',
order: 0,
aspectRatio: '16/9', // any CSS aspect-ratio value e.g. '4/3', '1/1'
showArrows: true,
showDots: true,
items: [
{
id: 'img-1',
type: 'image',
url: 'https://example.com/photo.jpg',
alt: 'A product photo',
caption: 'Shown as an overlay at the bottom of the slide',
},
{
id: 'vid-1',
type: 'video',
url: 'https://example.com/demo.mp4',
caption: 'Product walkthrough',
},
],
elements: [],
}List & Detail Section
A master-list / detail-panel layout — the canonical pattern for CRM records, settings screens, and admin dashboards. The left pane shows a searchable, paginated list; the right pane renders one or more detail pages each containing grid/flex sections with fields.
{
id: 'contacts',
layout: 'list-detail',
order: 0,
// ── Left pane ─────────────────────────────────────────────────────────
listTitle: 'Contacts',
listWidth: '280px',
pageSize: 50,
listItems: [
{ id: 'c1', label: 'Alice Smith', sublabel: '[email protected]', badge: 'Admin' },
{ id: 'c2', label: 'Bob Jones', sublabel: '[email protected]' },
{ id: 'c3', label: 'Carol Brown', sublabel: '[email protected]', avatar: 'CB' },
],
// ── API endpoints (appended automatically at runtime) ─────────────────
listEndpoint: {
url: 'https://api.example.com/contacts',
fromParam: 'from', toParam: 'to', sortParam: 'sort',
fromValue: 1, toValue: 50, sortValue: 'asc',
// resolves to: /contacts?from=1&to=50&sort=asc
},
filterEndpoint: {
url: 'https://api.example.com/contacts/search',
queryParam: 'query',
// resolves to: /contacts/search?query=alice
},
detailEndpoint: {
url: 'https://api.example.com/contacts',
selectedParam: 'selected',
// resolves to: /contacts?selected=c1
},
// ── Right pane: one or more detail pages ──────────────────────────────
// Multiple pages → tab bar appears above the detail area automatically.
detailPages: [
{
id: 'dp-overview',
title: 'Overview',
order: 0,
sections: [
{
id: 'dp-name',
layout: 'grid',
order: 0,
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '1rem',
elements: [
{ id: 'dp-first', name: 'firstName', type: 'input', inputType: 'text', label: 'First name', order: 0 },
{ id: 'dp-last', name: 'lastName', type: 'input', inputType: 'text', label: 'Last name', order: 1 },
],
},
],
},
{
id: 'dp-notes',
title: 'Notes',
order: 1,
sections: [
{
id: 'dp-notes-sec',
layout: 'flex',
order: 0,
flexDirection: 'column',
gap: '0.75rem',
elements: [
{ id: 'dp-note', name: 'notes', type: 'textarea', label: 'Notes', rows: 6, order: 0 },
],
},
],
},
],
elements: [],
}For very large datasets (thousands of rows), enable virtualization:
{
"id": "big-list",
"layout": "list-detail",
"virtualScrolling": true,
"virtualListHeight": 600,
"listItems": [...]
}See Virtualization for Large Lists for details.
Tree & Detail Section
A hierarchical tree view with a detail panel — ideal for representing
organisational structures, file systems, or nested categories. The left pane
shows an expandable/collapsible tree of nodes; the right pane renders the
detail panel for the selected node (same detailPages / flat elements pattern
as list-detail).
{
id: 'org-tree',
layout: 'tree-view',
order: 0,
// ── Left pane ─────────────────────────────────────────────────────────
treeTitle: 'Organisation',
treeWidth: '280px',
treeMode: 'easy', // 'easy' | 'compact' (default: 'easy')
treeNodes: [
{
id: 'eng',
label: 'Engineering',
badge: 'Dept',
children: [
{ id: 'fe', label: 'Frontend', sublabel: '12 members' },
{ id: 'be', label: 'Backend', sublabel: '10 members' },
],
},
{
id: 'design',
label: 'Design',
badge: 'Dept',
},
],
// ── Right pane — same options as list-detail ──────────────────────────
detailPages: [...], // optional — falls back to flat elements
elements: [],
}| Field | Type | Default | Description |
|---|---|---|---|
| treeTitle | string | — | Heading above the tree pane |
| treeWidth | string | '260px' | CSS width of the tree pane |
| treeMode | 'easy' \| 'compact' | 'easy' | 'easy' = larger rows; 'compact' = tighter spacing |
| treeNodes | TreeViewNode[] | [] | Root-level nodes (nested via children) |
| detailPages | DetailPage[] | — | Multi-page detail panel (same as list-detail) |
| elements | UIElementConfig[] | [] | Flat elements for a simple detail panel |
Chat Section
A chat window with a searchable conversations list on the left and an active
chat panel on the right. The right pane displays messages as speech bubbles
and provides a textarea + send button at the bottom. Reuses the same list
rendering pattern as list-detail.
{
id: 'team-chat',
layout: 'chat',
order: 0,
// ── Left pane ─────────────────────────────────────────────────────────
listTitle: 'Messages',
listWidth: '280px',
currentUserName: 'You',
conversations: [
{
id: 'g1',
label: 'Design Team',
sublabel: 'Alice: Looks great! 🎉',
avatar: 'DT',
badge: '3',
messages: [
{ id: 'm1', text: 'Hey team!', sender: 'Alice', role: 'other', timestamp: '2024-01-15T09:00:00Z', avatar: 'AL' },
{ id: 'm2', text: 'Hi Alice!', sender: 'You', role: 'me', timestamp: '2024-01-15T09:01:00Z' },
],
},
{
id: 'dm1',
label: 'Bob Smith',
sublabel: 'Can you review my PR?',
avatar: 'https://i.pravatar.cc/150?img=3',
messages: [],
},
],
// ── Right pane ────────────────────────────────────────────────────────
inputPlaceholder: 'Type a message…',
sendButtonText: 'Send',
elements: [],
}| Field | Type | Default | Description |
|---|---|---|---|
| listTitle | string | 'Messages' | Heading shown above the conversations pane |
| listWidth | string | '280px' | CSS width of the conversations pane |
| conversations | ChatConversation[] | [] | Conversation / group entries |
| inputPlaceholder | string | 'Type a message…' | Placeholder for the message textarea |
| sendButtonText | string | 'Send' | Label of the send button |
| currentUserName | string | 'You' | Display name used for sent messages |
ChatConversation fields
| Field | Type | Description |
|---|---|---|
| id | string | Unique identifier |
| label | string | Display name |
| sublabel | string | Last message preview |
| avatar | string | Avatar URL or ≤ 2-char initials |
| badge | string | Optional badge text (e.g. unread count) |
| messages | ChatMessage[] | Pre-seeded messages for this conversation |
ChatMessage fields
| Field | Type | Description |
|---|---|---|
| id | string | Unique identifier |
| text | string | Message body |
| sender | string | Sender's display name |
| role | 'me' \| 'other' | 'me' = right-aligned; 'other' = left-aligned |
| timestamp | string | ISO 8601 timestamp (optional) |
| avatar | string | Avatar URL or ≤ 2-char initials for the sender |
Navbar / Header Section
A top app bar with a logo, navigation links, and an auto-generated mobile hamburger menu. Supports light and dark themes, and static, sticky, or fixed positioning.
{
id: 'top-nav',
layout: 'navbar',
order: 0,
// Logo — text, image, or both
logoText: 'My App',
logoUrl: 'https://example.com/logo.svg', // optional
// Navigation links
links: [
{ id: 'home', label: 'Home', href: '/', active: true },
{ id: 'about', label: 'About', href: '/about' },
{ id: 'contact', label: 'Contact', href: '/contact' },
],
position: 'sticky', // 'static' | 'sticky' | 'fixed' (default: 'static')
theme: 'light', // 'light' | 'dark' (default: 'light')
elements: [],
}Sidebar / Drawer Section
A collapsible side navigation panel. Items support nested children (rendered with indentation) and an active state highlight.
{
id: 'main-sidebar',
layout: 'sidebar',
order: 0,
title: 'Navigation',
width: '260px', // CSS width when expanded (default: '260px')
defaultCollapsed: false, // start collapsed (default: false)
collapsible: true, // show toggle button (default: true)
items: [
{ id: 'dash', label: 'Dashboard', href: '/dash', active: true },
{ id: 'projects', label: 'Projects', href: '/projects',
children: [
{ id: 'active', label: 'Active', href: '/projects/active' },
{ id: 'archived', label: 'Archived', href: '/projects/archived' },
],
},
{ id: 'settings', label: 'Settings', href: '/settings' },
],
elements: [],
}Breadcrumbs Section
A hierarchical location trail. The last item is rendered as plain text (current page); all preceding items are rendered as links.
{
id: 'breadcrumb-trail',
layout: 'breadcrumbs',
order: 0,
separator: '/', // default '/'
items: [
{ id: 'home', label: 'Home', href: '/' },
{ id: 'projects', label: 'Projects', href: '/projects' },
{ id: 'current', label: 'React Ubiquitous' }, // last item — no href
],
elements: [],
}Pagination Section
Page-number controls for navigating long lists. Manages active-page state
internally; currentPage sets the initial page.
{
id: 'results-pager',
layout: 'pagination',
order: 0,
totalItems: 243, // total number of items across all pages
pageSize: 20, // items per page (default: 10)
currentPage: 1, // initial page, 1-based (default: 1)
maxPageButtons: 7, // max page-number buttons before truncation (default: 7)
showPrevNext: true, // show ‹ / › buttons (default: true)
showFirstLast: false, // show « / » jump-to-end buttons (default: false)
elements: [],
}Stepper Section
A multi-step wizard progress indicator. Manages currentStep state internally;
steps are clickable to navigate backward. Step status is derived from
currentStep unless an explicit status override is supplied on the step.
{
id: 'checkout-steps',
layout: 'stepper',
order: 0,
orientation: 'horizontal', // 'horizontal' | 'vertical' (default: 'horizontal')
currentStep: 1, // 0-based initial step (default: 0)
steps: [
{ id: 's1', label: 'Cart', description: 'Review your items' },
{ id: 's2', label: 'Shipping', description: 'Enter your address' },
{ id: 's3', label: 'Payment', description: 'Confirm and pay' },
{ id: 's4', label: 'Done', description: 'Order confirmed' },
],
elements: [],
}Standalone Tabs Section
A general-purpose tabbed content container. Unlike UIStage (which uses pages
as top-level tabs), this section renders tabs anywhere inside a page —
including nested inside another section. Each tab can contain its own sections
or a flat list of elements.
{
id: 'feature-tabs',
layout: 'tabs',
order: 0,
defaultTabId: 'tab-overview',
tabs: [
{
id: 'tab-overview',
label: 'Overview',
// Sections with full grid/flex layout support
sections: [
{
id: 'overview-grid',
layout: 'grid',
order: 0,
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '1rem',
elements: [
{ id: 'ov-name', name: 'name', type: 'input', inputType: 'text', label: 'Name', order: 0 },
],
},
],
},
{
id: 'tab-notes',
label: 'Notes',
// Flat elements when no sections are needed
elements: [
{ id: 'note', name: 'notes', type: 'textarea', label: 'Notes', rows: 5, order: 0 },
],
},
],
elements: [],
}Accordion Section
An expand/collapse accordion with one or more independently labelled panels.
By default only one panel may be open at a time (allowMultiple: false). Each
panel can contain its own nested sections (grid/flex) or a flat list of
elements. defaultOpen: true on a panel causes it to start open.
{
id: 'faq-accordion',
layout: 'accordion',
order: 0,
allowMultiple: false, // true = multiple panels may be open (default: false)
panels: [
{
id: 'p1',
label: 'What is react-ubiquitous?',
description: 'A brief answer', // optional subtitle in the header
defaultOpen: true, // open on first render (default: false)
elements: [
{ id: 'body1', name: 'body1', type: 'label', label: 'It is a JSON-driven UI renderer.', order: 0 },
],
},
{
id: 'p2',
label: 'How do I install it?',
// sections: [ ... ] ← use sections for rich grid/flex layouts
elements: [
{ id: 'body2', name: 'body2', type: 'label', label: 'Run npm install react-ubiquitous.', order: 0 },
],
},
],
elements: [],
}Collapse / Spoiler Section
A single expand/collapse toggle that hides content until the user clicks the trigger. Simpler than an Accordion when only one collapsible region is needed.
{
id: 'advanced-options',
layout: 'collapse',
order: 0,
label: 'Advanced options', // trigger label (falls back to `title`)
description: 'Rarely changed', // optional subtitle on the trigger
defaultOpen: false, // start open (default: false)
icon: true, // show chevron icon (default: true)
elements: [
{ id: 'timeout', name: 'timeout', type: 'input', inputType: 'number', label: 'Timeout (ms)', order: 0 },
],
}Divider Section
A visual separator line with an optional centred text label. Useful for grouping sections or form fields with a lightweight visual boundary.
{
id: 'section-sep',
layout: 'divider',
order: 0,
label: 'OR', // optional centred label (omit for a plain line)
orientation: 'horizontal', // 'horizontal' | 'vertical' (default: 'horizontal')
variant: 'solid', // 'solid' | 'dashed' | 'dotted' (default: 'solid')
elements: [],
}Card Section
A generic content container with optional header and footer slots.
title and description populate the header; elements populate the body;
footerElements populate a distinct footer row.
{
id: 'user-card',
layout: 'card',
order: 0,
title: 'Profile', // header title (optional)
description: 'Your public info', // header subtitle (optional)
bordered: true, // border around the card (default: true)
shadow: 'sm', // false | 'sm' | 'md' | 'lg' (default: 'sm')
padded: true, // body padding (default: true)
// Card body
elements: [
{ id: 'name', name: 'name', type: 'input', inputType: 'text', label: 'Name', order: 0 },
{ id: 'email', name: 'email', type: 'input', inputType: 'email', label: 'Email', order: 1 },
],
// Card footer (e.g. action buttons)
footerElements: [
{ id: 'save', name: 'save', type: 'button', label: 'Save', order: 0 },
{ id: 'cancel', name: 'cancel', type: 'button', label: 'Cancel', order: 1 },
],
}Iframe / Embed Section
Embeds an external URL in an <iframe>. The rendered src is recomputed
automatically whenever queryParams changes — update the object and the iframe
navigates to the new URL without a full component remount.
{
id: 'analytics-embed',
layout: 'iframe',
order: 0,
title: 'Sales Dashboard', // optional heading above the frame
// Base URL
src: 'https://analytics.example.com/dashboard',
// Merged onto the URL as ?key=value&…
// Changing this object causes the iframe to navigate to the new URL.
queryParams: {
tab: 'revenue',
year: 2025,
region: 'EU',
},
// resolves to: …/dashboard?tab=revenue&year=2025®ion=EU
frameHeight: '600px', // default '480px'
frameWidth: '100%', // default '100%'
frameTitle: 'Sales analytics', // accessible title (required for screen readers)
sandbox: 'allow-scripts allow-same-origin', // omit for no restrictions
allowFullscreen: true, // default true
showLoader: true, // spinner while loading (default true)
elements: [],
}To change a query parameter at runtime, update queryParams in your stage
config — no page reload needed:
// Switch from 2025 to 2026 — the iframe src updates immediately
section.queryParams = { ...section.queryParams, year: 2026 }Chart Section
Responsive, zero-dependency SVG charts that inherit the shadcn/tailwind design-token palette. Set layout: 'chart'.
Chart types (chartType):
| Value | Description |
|---|---|
| 'bar' | Grouped vertical bar chart |
| 'line' | Multi-series line chart with data-point dots |
| 'area' | Line chart with gradient-filled area below the curve |
| 'pie' | Pie chart with built-in legend |
| 'donut' | Ring chart with center total label |
| 'radar' | Spider / polygon chart with grid polygons |
| 'scatter' | X / Y scatter plot |
Key fields:
| Field | Type | Default | Description |
|---|---|---|---|
| chartType | string | — | Required — selects the chart variant |
| data | ChartDataPoint[] | — | Required — array of { label, ...seriesKeys } objects |
| series | ChartSeries[] | [{ key: 'value' }] | Named series: { key, label?, color? } |
| showGrid | boolean | true | Horizontal grid lines |
| showLegend | boolean | auto | Show series legend (auto-on for ≥ 2 series) |
| showLabels | boolean | false | Value labels on bars / points |
| height | number | 300 | Chart height in pixels |
const config: ChartSectionConfig = {
layout: 'chart',
id: 'revenue',
chartType: 'bar',
title: 'Monthly Revenue',
elements: [],
data: [
{ label: 'Jan', revenue: 42000, cost: 31000 },
{ label: 'Feb', revenue: 55000, cost: 38000 },
{ label: 'Mar', revenue: 49000, cost: 34000 },
],
series: [
{ key: 'revenue', label: 'Revenue' },
{ key: 'cost', label: 'Cost' },
],
showGrid: true,
height: 320,
}All charts are responsive (SVG viewBox), theme-aware (--chart-1..5 CSS vars), and dark-mode ready.
Charts
react-ubiquitous ships seven zero-dependency SVG chart types that any consuming project can use. All charts are:
- Responsive — SVG
viewBoxfills the container width automatically - Theme-aware — consume
--chart-1…--chart-5CSS variables from the shadcn/Tailwind palette - Dark-mode ready — no hardcoded colours
- Interactive — hover tooltips on every chart type (exact values, labels, percentages)
How charts are exposed
All charts are rendered through UISection with layout: 'chart'. There is no separate <BarChart /> etc. — the chart variant is selected with the chartType field:
import 'react-ubiquitous/styles.css' // ← required: CSS variables + Tailwind utilities
import { UISection } from 'react-ubiquitous'
import type { ChartSectionConfig } from 'react-ubiquitous'
const config: ChartSectionConfig = {
id: 'my-chart',
layout: 'chart',
chartType: 'bar', // ← swap this for any chart type
title: 'Monthly Revenue',
data: [
{ label: 'Jan', value: 42000 },
{ label: 'Feb', value: 55000 },
{ label: 'Mar', value: 49000 },
],
elements: [],
}
export function MyChart() {
return <UISection config={config} />
}Bar chart
chartType: 'bar' — vertical grouped bars. Supports single and multi-series.
const config: ChartSectionConfig = {
id: 'revenue-bar',
layout: 'chart',
chartType: 'bar',
title: 'Monthly Revenue',
showGrid: true,
showLabels: false, // set true to render value labels above bars
height: 300,
data: [
{ label: 'Jan', value: 42000 },
{ label: 'Feb', value: 55000 },
{ label: 'Mar', value: 49000 },
{ label: 'Apr', value: 62000 },
],
elements: [],
}Line chart
chartType: 'line' — polyline with data-point dots. Hover snaps a crosshair to the nearest column.
const config: ChartSectionConfig = {
id: 'traffic-line',
layout: 'chart',
chartType: 'line',
title: 'Site Traffic',
description: 'Unique visitors per month',
showGrid: true,
height: 300,
data: [
{ label: 'Jan', value: 3200 },
{ label: 'Feb', value: 4100 },
{ label: 'Mar', value: 3800 },
{ label: 'Apr', value: 5200 },
{ label: 'May', value: 6100 },
{ label: 'Jun', value: 5700 },
],
elements: [],
}Area chart
chartType: 'area' — line chart with a gradient-filled area below the curve.
const config: ChartSectionConfig = {
id: 'downloads-area',
layout: 'chart',
chartType: 'area',
title: 'Downloads Over Time',
showGrid: true,
height: 300,
data: [
{ label: 'Jan', value: 1200 },
{ label: 'Feb', value: 1900 },
{ label: 'Mar', value: 2400 },
{ label: 'Apr', value: 2100 },
{ label: 'May', value: 3500 },
{ label: 'Jun', value: 4200 },
],
elements: [],
}Pie chart
chartType: 'pie' — full pie with built-in legend. Set showLabels: true to render percentage labels on each slice.
const config: ChartSectionConfig = {
id: 'market-pie',
layout: 'chart',
chartType: 'pie',
title: 'Market Share',
showLabels: true,
height: 300,
data: [
{ label: 'Product A', value: 38 },
{ label: 'Product B', value: 27 },
{ label: 'Product C', value: 19 },
{ label: 'Product D', value: 10 },
{ label: 'Other', value: 6 },
],
elements: [],
}Hovering a slice scales it up and dims the rest; the tooltip shows the label, value, and percentage.
Donut chart
chartType: 'donut' — ring chart with a centre label displaying the total.
const config: ChartSectionConfig = {
id: 'region-donut',
layout: 'chart',
chartType: 'donut',
title: 'Sales by Region',
description: 'Proportion of total sales by geographic region',
height: 300,
data: [
{ label: 'North America', value: 42 },
{ label: 'Europe', value: 31 },
{ label: 'Asia Pacific', value: 18 },
{ label: 'Other', value: 9 },
],
elements: [],
}Radar chart
chartType: 'radar' — spider / polygon chart with concentric grid polygons. Works with single or multiple series. Each axis corresponds to a label in the data array.
const config: ChartSectionConfig = {
id: 'skills-radar',
layout: 'chart',
chartType: 'radar',
title: 'Skill Assessment',
height: 320,
data: [
{ label: 'Communication', value: 85 },
{ label: 'Teamwork', value: 90 },
{ label: 'Creativity', value: 70 },
{ label: 'Problem Solving', value: 95 },
{ label: 'Leadership', value: 75 },
{ label: 'Adaptability', value: 80 },
],
elements: [],
}Hovering an axis label highlights it and shows all series values for that axis in the tooltip.
Scatter chart
chartType: 'scatter' — X / Y scatter plot. Each data point must have an x and y key. label is shown in the tooltip.
const config: ChartSectionConfig = {
id: 'price-demand-scatter',
layout: 'chart',
chartType: 'scatter',
title: 'Price vs Demand',
description: 'Correlation between unit price and demand volume',
showGrid: true,
height: 300,
data: [
{ label: 'P1', x: 10, y: 95 },
{ label: 'P2', x: 20, y: 85 },
{ label: 'P3', x: 35, y: 70 },
{ label: 'P4', x: 50, y: 58 },
{ label: 'P5', x: 65, y: 44 },
{ label: 'P6', x: 75, y: 32 },
{ label: 'P7', x: 90, y: 18 },
],
elements: [],
}Multi-series
All axis-based chart types (bar, line, area, radar) accept a series array to plot multiple datasets from the same data objects. Each entry maps a key in the data to a display label and optional colour.
const config: ChartSectionConfig = {
id: 'financial-overview',
layout: 'chart',
chartType: 'bar', // also works with 'line', 'area', 'radar'
title: 'Financial Overview',
height: 300,
data: [
{ label: 'Jan', revenue: 42000, expenses: 28000, profit: 14000 },
{ label: 'Feb', revenue: 51000, expenses: 31000, profit: 20000 },
{ label: 'Mar', revenue: 48000, expenses: 29000, profit: 19000 },
{ label: 'Apr', revenue: 62000, expenses: 34000, profit: 28000 },
],
series: [
{ key: 'revenue', label: 'Revenue' },
{ key: 'expenses', label: 'Expenses' },
{ key: 'profit', label: 'Profit' },
],
showGrid: true,
showLegend: true,
elements: [],
}When series is omitted the chart falls back to a single { key: 'value' } series.
Custom colours
By default charts cycle through CSS variables --chart-1 … --chart-5. Override per-series with an explicit color:
series: [
{ key: 'revenue', label: 'Revenue', color: '#6366f1' },
{ key: 'expenses', label: 'Expenses', color: '#f43f5e' },
]Any valid CSS colour string (hex, rgb(), hsl(), var(--my-token)) works.
Embedding in a full stage
Charts slot directly into a UIStage config alongside forms, tables, or any other section type:
import { UIStage } from 'react-ubiquitous'
import type { UIStageConfig } from 'react-ubiquitous'
const dashboardConfig: UIStageConfig = {
id: 'analytics-dashboard',
title: 'Analytics Dashboard',
pages: [
{
id: 'overview',
title: 'Overview',
sections: [
{
id: 'revenue-chart',
layout: 'chart',
chartType: 'line',
title: 'Revenue Trend',
showGrid: true,
height: 280,
data: [
{ label: 'Q1', value: 120000 },
{ label: 'Q2', value: 145000 },
{ label: 'Q3', value: 138000 },
{ label: 'Q4', value: 182000 },
],
elements: [],
},
{
id: 'share-donut',
layout: 'chart',
chartType: 'donut',
title: 'Revenue by Product',
height: 280,
data: [
{ label: 'Enterprise', value: 60 },
{ label: 'Pro', value: 28 },
{ label: 'Free', value: 12 },
],
elements: [],
},
],
},
],
}
export function Dashboard() {
return <UIStage config={dashboardConfig} />
}Chart TypeScript imports
import type {
ChartSectionConfig, // full section config (use as config object type)
ChartDataPoint, // { label: string; [key: string]: unknown }
ChartSeries, // { key: string; label?: string; color?: string }
} from 'react-ubiquitous'All chart types share the same ChartSectionConfig interface — chartType is the discriminating field.
Element Types
All elements extend a shared base:
| Field | Type | Description |
|---|---|---|
| id | string | HTML id attribute |
| name | string | Key emitted by onChange |
| label | string | Visible label text |
| type | string | Discriminant — see types below |
| order | number | Sort order within section |
| labelPosition | 'top' \| 'left' \| 'right' \| 'hidden' | Label placement (default 'top') |
| tooltip | string | Renders a ? icon with tooltip text |
| units | string | Unit suffix/prefix (e.g. "kg", "$") |
| unitsPosition | 'prefix' \| 'suffix' | Default 'suffix' |
| disabled | boolean | Disables the control |
| readonly | boolean | Makes the control read-only |
| required | boolean | Adds HTML required attribute |
| hidden | boolean | Hides the field entirely |
| hiddenExpr | string | Condition expression — hides the field when truthy (e.g. "{age} >= 18") |
| disabledExpr | string | Condition expression — disables the field when truthy (e.g. "{role} !== \"admin\"") |
| width | number \| string | Column span 1–12, or any CSS string |
| className | string | Extra CSS classes on the wrapper |
| style | CSSProperties | Inline styles on the wrapper |
| validations | ValidationRule[] | Validation rules (evaluated on blur) |
input
{ type: 'input', inputType: 'text', placeholder: '', defaultValue: '', min, max, step, multiple, accept, datalistId }inputType accepts any valid HTML <input> type: text, email, password,
number, tel, url, date, time, datetime-local, month, week,
color, range, file, search.
textarea
{ type: 'textarea', placeholder: '', rows: 4, cols, resize: 'vertical', defaultValue: '' }resize: 'none' | 'both' | 'horizontal' | 'vertical'
select
{
type: 'select',
options: [
{ label: 'Option A', value: 'a' },
{ label: 'Option B', value: 'b' },
{ group: true, label: 'Group', options: [{ label: 'C', value: 'c' }] },
],
multiple: false,
defaultValue: 'a',
}radio
{ type: 'radio', options: [{ label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }], orientation: 'horizontal' }checkbox
{ type: 'checkbox', defaultChecked: false }button
{ type: 'button', text: 'Submit', buttonType: 'submit', variant: 'default', size: 'md' }variant: 'default' | 'destructive' | 'outline' | 'ghost' | 'link'size: 'sm' | 'md' | 'lg'
label
{ type: 'label', text: 'Section heading', htmlFor: 'some-input-id' }fieldset
Recursive — a fieldset contains its own elements array rendered as a labelled group.
{ type: 'fieldset', legend: 'Address', children: [ ...elements ] }datalist
Pairs with an input via datalistId to provide autocomplete suggestions.
{ type: 'datalist', id: 'colours-list', options: [{ label: 'Red', value: 'red' }] }output
Read-only display field with optional number formatting.
{ type: 'output', value: '1234.5', format: 'currency', locale: 'en-US', currency: 'USD' }format: 'text' | 'number' | 'currency' | 'percentage'
custom
Renders a consumer-supplied React component. See Custom Component Injection.
{ type: 'custom', component: 'my-widget', props: { max: 5 } }Validation
Rules are evaluated in order on blur. The first failure renders its message
below the field. Every rule accepts an optional message override.
validations: [
{ rule: 'required' },
{ rule: 'minLength', value: 8, message: 'Password must be at least 8 characters.' },
{ rule: 'maxLength', value: 64 },
{ rule: 'pattern', value: '^[A-Za-z0-9]+$', message: 'Alphanumeric only.' },
]| Rule | Extra field | Default message |
|---|---|---|
| required | — | "This field is required." |
| min | value: number | "Minimum value is {value}." |
| max | value: number | "Maximum value is {value}." |
| minLength | value: number | "Minimum length is {value} characters." |
| maxLength | value: number | "Maximum length is {value} characters." |
| pattern | value: string (regex) | "Invalid format." |
| email | — | "Invalid email address." |
| url | — | "Invalid URL." |
| phone | — | "Invalid phone number." |
| step | value: number | "Value must be a multiple of {value}." |
| custom | value: string | Resolved via your validation registry |
Validation AND / OR Chaining
Use a ValidationGroup entry to express AND / OR logic:
validations: [
{ rule: 'required' },
{
rule: 'group',
operator: 'or',
rules: [{ rule: 'email' }, { rule: 'url' }],
message: 'Must be a valid email address or URL.',
},
]operator: 'and'— all nested rules must pass (short-circuits on first failure).operator: 'or'— at least one nested rule must pass; the groupmessageis shown when all branches fail.- Groups can be nested to any depth to express complex boolean logic.
- The
messagefield is optional; a default message is used when omitted.
// Nested: (email) OR (minLength(5) AND uppercase)
{
rule: 'group',
operator: 'or',
rules: [
{ rule: 'email' },
{
rule: 'group',
operator: 'and',
rules: [
{ rule: 'minLength', value: 5 },
{ rule: 'pattern', value: '[A-Z]', message: 'Must contain uppercase.' },
],
},
],
message: 'Must be an email or a strong code (5+ chars with uppercase).',
}Computed / Derived Fields (Formulas)
Add a formula to any output element to derive its value from sibling fields:
{
"type": "output",
"id": "total",
"name": "total",
"label": "Order Total",
"formula": "{price} * {quantity}",
"format": "currency"
}- Field values are referenced by
nameinside{}braces. - Supports
+,-,*,/, parentheses, decimal literals. - Missing or non-numeric field references resolve to
0. - Unsafe expressions (characters outside
[\d\s+\-*/().]) returnnullsilently. - When a
formulais set,valueanddefaultValueare ignored.
Wiring up live formula evaluation
Pass the current form-values map to <UIStage>:
const [formValues, setFormValues] = useState<Record<string, unknown>>({})
<UIStage
config={stageConfig}
formValues={formValues}
onChange={(name, value) =>
setFormValues(prev => ({ ...prev, [name]: value }))
}
/>formValues flows automatically through UIPage → UISection → UIElement → OutputField.
useFormulaValue hook
For computing formula values outside of OutputField:
import { useFormulaValue, evaluateFormula } from 'react-ubiquitous'
// React hook (memoised, reacts to formValues changes)
const result = useFormulaValue('{price} * {qty}', formValues) // => "30"
// Pure function (synchronous)
const result = evaluateFormula('{price} * {qty}', { price: 10, qty: 3 }) // => 30Page-Transition Animations
Add pageTransition to UIStageConfig to animate tab changes:
{
"id": "my-stage",
"pageTransition": "fade",
"pages": [...]
}| Value | Effect |
|---|---|
| 'none' (default) | Instant switch, no animation |
| 'fade' | Incoming page fades in over 220 ms |
| 'slide-left' | New page slides in from the right (forward navigation feel) |
| 'slide-right' | New page slides in from the left (backward navigation feel) |
CSS keyframes are injected into <head> once on first render (SSR-safe).
TypeScript Type Generation from Config
Generate a typed FormValues interface from a stage config:
import { generateFormTypes } from 'react-ubiquitous'
const ts = generateFormTypes(myStageConfig)
// Outputs:
// export interface FormValues {
// firstName: string;
// lastName: string;
// email: string;
// age?: number;
// subscribe?: boolean;
// }- Pass an optional second argument for a custom type name:
generateFormTypes(config, 'ContactFormValues') - Required fields (
required: true) are typed as non-optional. - Non-value elements (
button,label,fieldset,datalist) are excluded. - Field names that are not valid JS identifiers are quoted.
onChange Callback
const [formData, setFormData] = useState<Record<string, unknown>>({})
<UIStage
config={stageConfig}
onChange={(name, value) =>
setFormData(prev => ({ ...prev, [name]: value }))
}
/>| Element | Value type |
|---|---|
| input text / email / tel / url / search | string |
| input number / range | number |
| input date / time / datetime-local | string |
| input file | FileList \| null |
| checkbox | boolean |
| radio | string |
| textarea | string |
| select (single) | string |
| select (multiple) | string[] |
Server-Side Validation
Pass server-returned validation errors directly to UIStage via serverErrors. Each key is a field name and the value is the error message to display.
// After a failed form submission:
const [serverErrors, setServerErrors] = useState<Record<string, string>>({})
const handleSubmit = async () => {
const response = await submitForm(formValues)
if (!response.ok) {
// Backend returns: { "email": "Email already in use.", "username": "Taken." }
const errors = await response.json()
setServerErrors(errors)
}
}
<UIStage
config={stageConfig}
serverErrors={serverErrors}
/>Server errors appear below client-side validation errors in the same field wrapper. They are keyed by the field's name attribute.
Conditional Rendering
Add hiddenExpr or disabledExpr to any element config to show / hide / disable it dynamically based on other field values.
{
"type": "input",
"id": "guardian-name",
"name": "guardianName",
"label": "Guardian Name",
"hiddenExpr": "{age} >= 18"
}{
"type": "input",
"id": "admin-notes",
"name": "adminNotes",
"label": "Admin Notes",
"disabledExpr": "{role} !== \"admin\""
}Expressions support: field references {fieldName}, comparison operators (<, >, <=, >=, ===, !==), logical operators (&&, ||, !), and arithmetic (+, -, *, /).
The static hidden and disabled flags still work as before — the expression props take precedence when present.
ReadOnly Mode
Add readOnly to UIStage to render the entire form in a non-editable state. Every field is forced into disabled mode. Useful for "View Details" pages that reuse the same form config as the edit page.
<UIStage config={stageConfig} readOnly />i18n / Validation Messages
Override the default English validation messages by passing an i18n prop to UIStage. Supports static strings or factory functions for dynamic messages.
import type { I18nMessages } from 'react-ubiquitous'
const i18n: I18nMessages = {
required: 'Ce champ est obligatoire.',
email: 'Adresse e-mail invalide.',
url: 'URL invalide.',
minLength: (n) => `Minimum ${n} caractères.`,
maxLength: (n) => `Maximum ${n} caractères.`,
min: (v) => `Valeur minimum : ${v}.`,
max: (v) => `Valeur maximum : ${v}.`,
}
<UIStage config={stageConfig} i18n={i18n} />Any key that is omitted falls back to the built-in English message. Per-field message overrides in the config always take the highest priority.
Exported type: I18nMessages — import from react-ubiquitous.
Form Persistence & Auto-Save
Persist form values across page refreshes with localStorageKey. An optional onAutoSave callback fires (debounced 1 s) whenever values change.
<UIStage
config={stageConfig}
localStorageKey="contact-form-draft"
onAutoSave={(values) => {
console.log('Auto-saved:', values)
// e.g. POST to /api/drafts
}}
/>Saved values are merged into the form's initial state on the next page load. If you also pass formValues, the controlled values take precedence over saved ones.
You can also use the hook directly in your own components:
import { useFormPersistence } from 'react-ubiquitous'
const { savedValues, persistValues } = useFormPersistence({
localStorageKey: 'my-form',
onAutoSave: (values) => console.log('Saved', values),
debounceMs: 500, // default: 1000
})Custom Component Injection
Register bespoke React components to render inside the JSON config. This makes the library infinitely extensible without forking.
Step 1 — define a custom component:
import type { UIElementProps } from 'react-ubiquitous'
function MyRatingWidget({ config, onChange }: UIElementProps) {
const cfg = config as CustomElementConfig & { props?: { max?: number } }
return (
<div>
{/* your custom rendering */}
<input type="range" max={cfg.props?.max ?? 5} onChange={...} />
</div>
)
}Step 2 — pass it to UIStage:
<UIStage
config={stageConfig}
customComponents={{ 'my-rating': MyRatingWidget }}
/>Step 3 — reference it in your config:
{
"type": "custom",
"component": "my-rating",
"id": "satisfaction",
"name": "satisfaction",
"props": { "max": 10 }
}You can also register components globally via componentRegistry (useful for libraries or plugins):
import { componentRegistry } from 'react-ubiquitous'
componentRegistry.register('my-rating', MyRatingWidget)Exported type: CustomElementConfig — import from react-ubiquitous.
Virtualization for Large Lists
For list-detail sections with thousands of items, enable virtualScrolling to render only the visible rows using react-window:
{
"layout": "list-detail",
"id": "users-list",
"virtualScrolling": true,
"virtualListHeight": 600,
"listItems": [...]
}| Config | Type | Default | Description |
|--------|------|---------|-------------|
| virtualScrolling | boolean | false | Enables react-window row virtualization. Disables pagination. |
| virtualListHeight | number | 400 | Height of the virtual scroll container in pixels. |
Theme Builder
A standalone Theme Builder ships as a static HTML page at public/theme-builder.html. Open it in any browser (no build step required) to:
- Pick colors for every CSS variable
- Choose from preset color schemes (Indigo, Emerald, Rose, Amber, Sky, Dark)
- Adjust the global border-radius
- Preview changes live against buttons, inputs, badges, and cards
- Copy the generated
:root { ... }block to paste into your project stylesheet
# Start the Storybook dev server, then visit:
http://localhost:6006/theme-builder.html
# Or open directly:
open public/theme-builder.htmlHTTP Networking Client
react-ubiquitous ships a lightweight, zero-dependency HTTP client built on the
browser's native fetch API. It supports Basic and Bearer authentication, query
parameters, JSON request/response bodies, and request cancellation via
AbortSignal.
Quick usage
import { get, post, createHttpClient } from 'react-ubiquitous'
// One-off GET with Bearer auth
const { data, status, ok } = await get<User[]>('/api/users', {
auth: { type: 'bearer', token: 'my-token' },
params: { page: 1, limit: 20 },
})
// POST with a JSON body
await post('/api/users', {
auth: { type: 'bearer', token: 'my-token' },
body: { name: 'Alice', role: 'admin' },
})createHttpClient — pre-bound client
Bind a base URL and default auth once; all methods inherit them automatically. Per-request options can still override both.
import { createHttpClient } from 'react-ubiquitous'
const api = createHttpClient('https://api.example.com', {
type: 'bearer',
token: sessionStorage.getItem('token') ?? '',
})
// GET /contacts?from=1&to=50&sort=asc
const { data } = await api.get<Contact[]>('/contacts', {
params: { from: 1, to: 50, sort: 'asc' },
})
// POST /contacts (body serialised as JSON automatically)
await api.post('/contacts', { body: { name: 'Bob' } })
// DELETE /contacts/42
await api.del('/contacts/42')Available methods
| Export | Description |
|---|---|
| get(url, options?) | GET request |
| post(url, options?) | POST request |
| put(url, options?) | PUT request |
| patch(url, options?) | PATCH request |
| del(url, options?) | DELETE request |
| head(url, options?) | HEAD request (no response body) |
| request(method, url, options?) | Raw method for any HTTP verb |
| createHttpClient(baseUrl?, auth?) | Factory returning pre-bound helpers |
| buildAuthHeader(auth) | Returns the Authorization header value |
| buildRequestUrl(url, params?) | Appends query params to a URL |
Auth strategies
// Bearer token (RFC 6750)
{ type: 'bearer', token: 'eyJhbGci…' }
// HTTP Basic (RFC 7617) — Base-64 encodes username:password
{ type: 'basic', username: 'alice', password: 's3cr3t' }
// No authentication header
{ type: 'none' }RequestOptions
| Field | Type | Description |
|---|---|---|
| auth | AuthConfig | Override auth for this request |
| headers | Record<string, string> | Extra headers merged with defaults |
| params | Record<string, string \| number \| boolean> | Query-string parameters |
| body | unknown | Request payload — serialised as JSON |
| signal | AbortSignal | Cancellation signal |
HttpResponse<T>
| Field | Type | Description |
|---|---|---|
| data | T | Decoded response body (null when empty) |
| status | number | HTTP status code |
| headers | Headers | Raw Headers object from Fetch |
| ok | boolean | true when status is 200–299 |
Component API
<UIStage>
<UIStage
config={stageConfig} // UIStageConfig — required
onChange={(name, value) => {}} // called on every field change
onPageChange={(pageId) => {}} // called when the active tab changes
formValues={formValues} // optional — current field-value map for formula evaluation
serverErrors={{ email: 'Email already in use.' }} // server-side validation errors keyed by field name
readOnly // forces every element into disabled / read-only mode
customComponents={{ 'my-widget': MyWidget }} // map of custom element renderers
i18n={{ required: 'Ce champ est obligatoire.' }} // validation message overrides
localStorageKey="my-form" // auto-persists form values to localStorage
onAutoSave={(values) => {}} // debounced callback whenever values change
/>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| config | UIStageConfig | — | The stage configuration object. |
| onChange | (name, value) => void | — | Called on every field change. |
| onPageChange | (pageId) => void | — | Called when the active tab changes. |
| formValues | Record<string, unknown> | — | Controlled form values (for formula evaluation). |
| serverErrors | Record<string, string> | — | Server-supplied validation errors, keyed by field name. Displayed alongside client-side errors. |
| readOnly | boolean | false | Forces every element into a disabled state. Ideal for view-only pages. |
| customComponents | Record<string, ComponentType> | — | Map of custom React components for type: 'custom' elements. |
| i18n | I18nMessages | — | Validation message overrides for built-in English messages. |
| localStorageKey | string | — | Persist form values to localStorage under this key. Values are restored on first render. |
| onAutoSave | (values) => void | — | Debounced callback (1 s) invoked whenever form values change while localStorageKey is set. |
<UIPage>, <UISection>, <UIElement>
Lower-level components for custom layouts:
import { UIPage, UISection, UIElement } from 'react-ubiquitous'
<UIPage config={pageConfig} onChange={handleChange} formValues={formValues} />
<UISection config={sectionConfig} onChange={handleChange} formValues={formValues} />
<UIElement config={elementConfig} onChange={handleChange} formValues={formValues} />useValidation
import { useValidation } from 'react-ubiquitous'
const { error, validate, clear } = useValidation(element.validations)useFormulaValue
import { useFormulaValue } from 'react-ubiquitous'
const computed = useFormulaValue('{price} * {quantity}', formValues)
// => "30" (string) or null when formula is absent / invalidgenerateFormTypes
import { generateFormTypes } from 'react-ubiquitous'
const ts = generateFormTypes(stageConfig, 'MyFormValues')DynamicForm
DynamicForm is a standalone server-driven interactive form renderer. Unlike
UIStage (which hosts entire page layouts), DynamicForm is consumed directly
— pass the raw JSON response from any /api/forms/{name} endpoint as the
config prop and the component handles everything else.
Quick example
import { DynamicForm } from 'react-ubiquitous'
function AuthPage({ serverPrefix }: { serverPrefix: string }) {
const [config, setConfig] = React.useState(null)
React.useEffect(() => {
fetch(`${serverPrefix}/api/forms/auth`)
.then(r => r.json())
.then(setConfig)
}, [serverPrefix])
if (!config) return null
return (
<DynamicForm
config={config}
serverPrefix={serverPrefix}
onSubmit={values => console.log('Submitted', values)}
/>
)
}Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| config | DynamicFormConfig | — | JSON form config from the server. |
| serverPrefix | string | "" | Base URL prefix for async validateUrl and submitUrl. |
| onSubmit | (values) => void \| Promise<void> | — | Called after all validations pass. |
| onChange | (name, value) => void | — | Called on every field value change. |
| theme | UITheme | — | Visual theme token (inherits parent when omitted). |
| className | string | — | Additional class names on the <form> element. |
Validation
All validation is Zod-powered — schemas are built dynamically from the
validations array in each field config. Every rule runs inside a
superRefine so all errors are collected in a single safeParse pass.
Supported rules: required · minLength · maxLength · min · max ·
pattern · email · url · matchField · notSameAs · mustBeTrue ·
luhn · notExpired · async (debounced HTTP GET)
Button-level validations apply per-field constraints shown as amber hints
beneath the button so users know exactly what blocks submission.
Server contract
GET /api/forms/{formName} → DynamicFormConfig
GET {serverPrefix}{validateUrl}?field=&value= → { valid, error? }
POST {serverPrefix}{submitUrl} → anySee the api-specification.md for the
full JSON schema.
Storybook Component Catalogue
An interactive Storybook catalogue ships alongside the library source and showcases every major component with default Tailwind styles:
npm run storybook # dev server at http://localhost:6006
npm run build-storybook # static build to storybook-static/Stories are located in src/lib/stories/ and cover:
| Story | Variants |
|---|---|
| InputField | Text, Email, Password, Number, Disabled, OR Validation, Tooltip |
| SelectField | Default, Option Groups, Multi-Select |
| ButtonField | Default, Outline, Destructive, Ghost, Small, Large |
| CheckboxField | Unchecked, Checked, Disabled |
| OutputField | Static, Currency, Percentage, Formula, Nested Formula |
| UIStage | Contact Form, Fade/Slide Transitions, Formula Fields, AND/OR Validations, Dark Theme |
| Chart | Bar (single + multi-series + labels), Line (single + multi), Area (single + multi), Pie (default + labels), Donut, Radar (single + multi), Scatter |
| ListDetail | Contacts, Simple (flat elements), Avatar Images |
| TreeView | Organisation (easy mode), Compact Mode, Detail Pages |
| Chat | Group Conversations, Direct Messages, Empty State |
All stories are also automatically smoke-tested in CI via
@storybook/addon-vitest + Playwright Chromium as part of npm test.
TypeScript Reference
All config interfaces are exported:
import type {
// Stage / Page
UIStageConfig,
UIPageConfig,
// Section union + individual types
UISectionConfig,
FlexSectionConfig,
GridSectionConfig,
HeroSectionConfig,
MediaSectionConfig, MediaItem,
ListDetailSectionConfig,
ListDetailItem,
ListEndpointConfig,
FilterEndpointConfig,
DetailEndpointConfig,
DetailPage,
TreeViewSectionConfig,
TreeViewNode,
ChatSectionConfig,
ChatConversation,
ChatMessage,
// Navigation & Wayfinding section types
NavLink,
NavbarSectionConfig,
SidebarItem,
SidebarSectionConfig,
BreadcrumbItem,
BreadcrumbsSectionConfig,
PaginationSectionConfig,
StepperStep,
StepperSectionConfig,
TabItem,
TabsSectionConfig,
// Element union + individual types
UIElementConfig,
InputElementConfig,
CheckboxElementConfig,
RadioElementConfig,
TextareaElementConfig,
SelectElementConfig,
ButtonElementConfig,
LabelElementConfig,
FieldsetElementConfig,
DatalistElementConfig,
OutputElementConfig,
// Helpers
ValidationRule,
ValidationGroup, // AND / OR validation group
ElementWidth,
StyleObject,
UIPageTransition, // 'none' | 'fade' | 'slide-left' | 'slide-right'
} from 'react-ubiquitous'HTTP networking types:
import type {
AuthConfig, // 'basic' | 'bearer' | 'none' strategy
RequestOptions, // per-request options (auth, headers, params, body, signal)
HttpResponse, // { data, status, headers, ok }
} from 'react-ubiquitous'Styling
react-ubiquitous uses Tailwind CSS internally. The rendered HTML uses semantic
elements (<label>, <input>, <fieldset>, etc.) and each config accepts
className and style for customisation at every level.
Default theme stylesheet
The bundled stylesheet (dist/react-ubiquitous.css) contains both the
compiled Tailwind utility classes used by every component and the
shadcn-compatible CSS custom-property theme tokens (e.g. --background,
--foreground, --primary).
Import it once — in your app's entry point (or global CSS file):
// main.tsx / index.tsx (or your global CSS)
import 'react-ubiquitous/styles.css'This gives you the default neutral light theme out of the box, full component
styling, and a .dark variant for dark mode — matching the shadcn/ui default
palette. No additional Tailwind configuration is required.
If you already use shadcn/ui (or any Tailwind-based design system that defines the same CSS variables), you can still i
