Roovix UI
Production design system for real products. Every snippet below is copy-paste ready HTML or React — with real variables, real event handlers, and real API patterns.
Installation
NPM for apps, CDN for static HTML. Works alongside Tailwind.
npm install roovix-ui<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/roovix-ui@1.0.0/dist/roovix-ui.css" />
<script src="https://cdn.jsdelivr.net/npm/roovix-ui@1.0.0/dist/roovix-ui.js"></script>
<script>
Roovix.init(); // theme + auto-init
</script>// main.jsx / App.jsx
import 'roovix-ui/dist/roovix-ui.css';
import Roovix from 'roovix-ui';
Roovix.init();
export default function App() {
return <YourApp />;
}Theme
Dark is default. Persist preference with the Theme API.
<!-- Set initial theme on the root element -->
<html lang="en" data-theme="dark">
<button type="button" data-roovix-theme-toggle>Toggle theme</button>
<script>
// Read / set / toggle
const current = Roovix.Theme.get(); // 'dark' | 'light'
Roovix.Theme.set('light');
Roovix.Theme.toggle();
// React to changes
document.addEventListener('roovix:theme', (e) => {
console.log('Theme is now', e.detail.theme);
});
</script>import { useState, useEffect } from 'react';
import Roovix from 'roovix-ui';
function ThemeToggle() {
const [theme, setTheme] = useState(() => Roovix.Theme.get());
useEffect(() => {
const onTheme = (e) => setTheme(e.detail.theme);
document.addEventListener('roovix:theme', onTheme);
return () => document.removeEventListener('roovix:theme', onTheme);
}, []);
return (
<button
type="button"
className="btn btn-secondary"
onClick={() => setTheme(Roovix.Theme.toggle())}
>
Theme: {theme}
</button>
);
}Colors
Use CSS variables in stylesheets, inline styles, or React style objects. Toggle theme to see light/dark values update automatically.
Canvas
Foreground
Semantic
<!-- Utility classes shipped by Roovix -->
<div class="bg-canvas-subtle text-fg-default">Surface</div>
<p class="text-fg-muted">Secondary text</p>
<a class="text-accent" href="/docs">Accent link</a>
<span class="text-success">Success</span>
<span class="text-danger">Error</span>
<!-- Or raw CSS variables -->
<div style="
background: var(--color-canvas-elevated);
color: var(--color-fg-default);
border: 1px solid var(--color-border-default);
padding: var(--space-4);
border-radius: var(--radius-3);
">
Custom panel
</div>
<style>
.status-ok { color: var(--color-success-fg); background: var(--color-success-subtle); }
.status-err { color: var(--color-danger-fg); background: var(--color-danger-subtle); }
</style>// Option A — utility classes
export function StatusBanner({ ok, children }) {
return (
<div className={ok ? 'text-success' : 'text-danger'}>
{children}
</div>
);
}
// Option B — CSS variables in style objects
export function Panel({ children }) {
return (
<div
style={{
background: 'var(--color-canvas-elevated)',
color: 'var(--color-fg-default)',
border: '1px solid var(--color-border-default)',
padding: 'var(--space-4)',
borderRadius: 'var(--radius-3)',
}}
>
{children}
</div>
);
}
// Option C — read token at runtime (charts, canvas, etc.)
function getAccent() {
return getComputedStyle(document.documentElement)
.getPropertyValue('--color-accent-fg')
.trim();
}Typography
Font stacks and text roles. Content is always yours.
<h1 style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em">
Dashboard
</h1>
<h2 style="font-size:1.35rem;font-weight:600">Projects</h2>
<p>Body text uses the default foreground color.</p>
<p class="text-muted">Last updated 2 hours ago</p>
<p class="text-subtle text-sm">ID: proj_8f3a</p>
<code class="font-mono text-sm">npm install roovix-ui</code>
<!-- Font families from tokens -->
<style>
body { font-family: var(--font-sans); }
code, pre { font-family: var(--font-mono); }
</style>export function PageHeader({ title, description }) {
return (
<header style={{ marginBottom: 'var(--space-6)' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.03em' }}>
{title}
</h1>
{description && (
<p className="text-muted" style={{ marginTop: 'var(--space-2)' }}>
{description}
</p>
)}
</header>
);
}
// Usage
<PageHeader
title="Billing"
description="Manage invoices and payment methods for your workspace."
/>Spacing
Scale: --space-1 (4px) through --space-9 (96px). Use in margin, padding, and gap.
--space-1 4px--space-2 8px--space-3 12px--space-4 16px--space-5 24px--space-6 32px--space-7 48px--space-8 64px<div style="padding: var(--space-5); margin-bottom: var(--space-6)">
<div style="display:flex; gap: var(--space-3)">
<button class="btn btn-primary">Save</button>
<button class="btn btn-ghost">Cancel</button>
</div>
</div>
<!-- Utility helpers -->
<div class="mt-4 mb-5 gap-3" style="display:flex">…</div>const stack = {
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4)',
padding: 'var(--space-5)',
};
export function FormSection({ children }) {
return <section style={stack}>{children}</section>;
}Shadows
<div class="card shadow-md">Elevated card</div>
<div style="box-shadow: var(--shadow-lg); border-radius: var(--radius-3); padding: var(--space-4)">
Custom elevated surface
</div><div className="card shadow-lg">{children}</div>
<div style={{ boxShadow: 'var(--shadow-xl)', borderRadius: 'var(--radius-4)', padding: 'var(--space-5)' }}>
Modal-like surface
</div>Radius & Motion
<div style="border-radius: var(--radius-2)">8px corners</div>
<div style="border-radius: var(--radius-full)">Pill / avatar</div>
<style>
.fade-in {
transition: opacity var(--transition-base), transform var(--transition-slow) var(--ease-out-expo);
}
</style>const chip = {
borderRadius: 'var(--radius-full)',
padding: '0.15rem 0.6rem',
transition: 'background var(--transition-fast)',
};
<span style={chip}>Beta</span>Borders
<div class="border border-default rounded">Default border</div>
<div style="border: 1px solid var(--color-border-accent); border-radius: var(--radius-2)">
Focused / selected state
</div>
<div style="border: 1px solid var(--color-border-danger)">Validation error</div>function Fieldset({ error, children }) {
return (
<div
style={{
border: `1px solid var(--color-border-${error ? 'danger' : 'default'})`,
borderRadius: 'var(--radius-2)',
padding: 'var(--space-4)',
}}
>
{children}
</div>
);
}Badges & Tags
<span class="badge badge-success">Active</span>
<span class="badge badge-warning">Pending review</span>
<span class="tag tag-accent">design-system</span>
<!-- Map API status → badge class -->
<script>
const statusClass = {
active: 'badge-success',
pending: 'badge-warning',
failed: 'badge-danger',
archived: 'badge-done',
};
el.className = 'badge ' + statusClass[user.status];
el.textContent = user.status;
</script>const STATUS_BADGE = {
active: 'badge-success',
pending: 'badge-warning',
failed: 'badge-danger',
archived: 'badge-done',
};
export function StatusBadge({ status }) {
return (
<span className={`badge ${STATUS_BADGE[status] || 'badge-done'}`}>
{status}
</span>
);
}
// Usage with real API data
<StatusBadge status={user.status} />
<span className="tag tag-accent">{project.tag}</span>Forms
Controlled inputs, validation states, checkboxes, radios, switch, range — all values come from your state.
<form id="profileForm">
<label class="text-sm text-muted">Full name</label>
<input class="input" name="name" type="text" placeholder="Full name" required />
<label class="text-sm text-muted">Email</label>
<input class="input" name="email" type="email" id="email" />
<!-- toggle .error / .success from your validation -->
<label class="control">
<input type="checkbox" name="terms" id="terms" /> I agree to the terms
</label>
<label class="control">
<input type="radio" name="plan" value="free" /> Free
</label>
<label class="control">
<input type="radio" name="plan" value="pro" checked /> Pro
</label>
<label class="control">
<span class="switch">
<input type="checkbox" name="notify" id="notify" />
<span class="slider"></span>
</span>
Email notifications
</label>
<button type="submit" class="btn btn-primary">Create account</button>
</form>
<script>
document.getElementById('profileForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
// data = { name, email, terms: 'on', plan: 'pro', notify: 'on' }
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
document.getElementById('email').classList.add('error');
}
});
</script>import { useState } from 'react';
export function ProfileForm() {
const [form, setForm] = useState({
name: '',
email: '',
plan: 'pro',
terms: false,
notify: true,
});
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const set = (key) => (e) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setForm((f) => ({ ...f, [key]: value }));
};
const onSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setErrors({});
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const json = await res.json();
if (!res.ok) setErrors(json.errors || { email: 'Invalid email' });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', maxWidth: 420 }}>
<input className="input" value={form.name} onChange={set('name')} placeholder="Full name" />
<input
className={`input ${errors.email ? 'error' : ''}`}
type="email"
value={form.email}
onChange={set('email')}
placeholder="Email"
/>
{errors.email && <span className="text-danger text-sm">{errors.email}</span>}
<label className="control">
<input type="radio" name="plan" value="free" checked={form.plan === 'free'} onChange={set('plan')} /> Free
</label>
<label className="control">
<input type="radio" name="plan" value="pro" checked={form.plan === 'pro'} onChange={set('plan')} /> Pro
</label>
<label className="control">
<input type="checkbox" checked={form.terms} onChange={set('terms')} /> I agree to the terms
</label>
<label className="control">
<span className="switch">
<input type="checkbox" checked={form.notify} onChange={set('notify')} />
<span className="slider" />
</span>
Email notifications
</label>
<button type="submit" className="btn btn-primary" disabled={submitting || !form.terms}>
{submitting ? 'Creating…' : 'Create account'}
</button>
</form>
);
}Custom Select
Pass your options. Read the selected value from onChange, getValue(), or the roovix:change event.
<div class="custom-select" id="planSelect">
<button class="select-trigger" type="button">
<span>Choose plan…</span>
<span class="select-arrow"></span>
</button>
<div class="select-menu" role="listbox">
<div class="select-option" data-value="free" role="option">Free — $0/mo</div>
<div class="select-option" data-value="pro" role="option">Pro — $29/mo</div>
<div class="select-option" data-value="team" role="option">Team — $99/mo</div>
</div>
</div>
<p>Selected plan: <span id="planOut">—</span></p>
<script>
const planSelect = new Roovix.Select('#planSelect', {
placeholder: 'Choose plan…',
onChange: (value, optionEl) => {
// value === 'pro' | 'free' | 'team'
// optionEl.textContent === 'Pro — $29/mo'
document.getElementById('planOut').textContent = value;
console.log('label:', optionEl.textContent);
},
});
// Programmatic control
planSelect.setValue('pro'); // select by data-value
const current = planSelect.getValue(); // 'pro'
// Or listen on the element
document.getElementById('planSelect').addEventListener('roovix:change', (e) => {
console.log('event value:', e.detail.value);
});
</script>import { useEffect, useRef, useState } from 'react';
import Roovix from 'roovix-ui';
const PLANS = [
{ value: 'free', label: 'Free — $0/mo' },
{ value: 'pro', label: 'Pro — $29/mo' },
{ value: 'team', label: 'Team — $99/mo' },
];
function PlanSelect({ value, onChange }) {
const ref = useRef(null);
const inst = useRef(null);
useEffect(() => {
inst.current = new Roovix.Select(ref.current, {
placeholder: 'Choose plan…',
onChange: (v) => onChange(v),
});
}, []);
useEffect(() => {
if (value != null) inst.current?.setValue(value);
}, [value]);
return (
<div className="custom-select" ref={ref}>
<button className="select-trigger" type="button">
<span>Choose plan…</span>
<span className="select-arrow" />
</button>
<div className="select-menu" role="listbox">
{PLANS.map((o) => (
<div key={o.value} className="select-option" data-value={o.value} role="option">
{o.label}
</div>
))}
</div>
</div>
);
}
// Parent — real selected value in state
function Checkout() {
const [plan, setPlan] = useState(null);
const startCheckout = async () => {
if (!plan) return;
await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId: plan }),
});
};
return (
<>
<PlanSelect value={plan} onChange={setPlan} />
<p>Selected: {plan ?? '—'}</p>
<button className="btn btn-primary" disabled={!plan} onClick={startCheckout}>
Continue
</button>
</>
);
}Cards
<div class="card">
<div style="font-weight:600">Acme Corp</div>
<div class="text-muted text-sm">Enterprise workspace · 42 members</div>
</div>
<div class="card card-stat">
<div class="stat-value" id="userCount">0</div>
<div class="stat-label">Active users</div>
<div class="stat-change positive" id="userDelta">—</div>
</div>
<script>
fetch('/api/metrics')
.then((r) => r.json())
.then((m) => {
document.getElementById('userCount').textContent = m.activeUsers.toLocaleString();
const el = document.getElementById('userDelta');
el.textContent = (m.delta > 0 ? '↑ ' : '↓ ') + Math.abs(m.delta) + '%';
el.className = 'stat-change ' + (m.delta >= 0 ? 'positive' : 'negative');
});
</script>function StatCard({ value, label, change }) {
const positive = change >= 0;
return (
<div className="card card-stat">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
<div className={`stat-change ${positive ? 'positive' : 'negative'}`}>
{positive ? '↑' : '↓'} {Math.abs(change)}%
</div>
</div>
);
}
// From API
function Metrics() {
const [m, setM] = useState(null);
useEffect(() => {
fetch('/api/metrics').then((r) => r.json()).then(setM);
}, []);
if (!m) return <div className="skeleton skeleton-card" />;
return <StatCard value={m.activeUsers.toLocaleString()} label="Active users" change={m.delta} />;
}Alerts & Toasts
<div class="alert alert-danger" id="formError" hidden>
<span class="alert-icon">✕</span>
<div id="formErrorMsg"></div>
</div>
<script>
function showError(message) {
const box = document.getElementById('formError');
document.getElementById('formErrorMsg').textContent = message;
box.hidden = false;
}
// After a failed API call:
// showError(err.message || 'Something went wrong');
</script>function Alert({ tone = 'accent', icon, children }) {
return (
<div className={`alert alert-${tone}`}>
{icon && <span className="alert-icon">{icon}</span>}
<div>{children}</div>
</div>
);
}
function Toast({ tone, title, description }) {
return (
<div className={`toast toast-${tone}`}>
<div className="toast-icon">{tone === 'success' ? '✓' : '✕'}</div>
<div className="toast-content">
<div className="toast-title">{title}</div>
<div className="toast-desc">{description}</div>
</div>
</div>
);
}
// Usage after mutation
{error && <Alert tone="danger" icon="✕">{error.message}</Alert>}
{saved && <Toast tone="success" title="Saved" description="Profile updated." />}Tabs
Tab labels and panels are your content. onChange gives you the active index.
<div class="tabs" id="settingsTabs">
<button class="tab active" type="button" data-panel="general">General</button>
<button class="tab" type="button" data-panel="security">Security</button>
<button class="tab" type="button" data-panel="billing">Billing</button>
</div>
<div id="panel-general">General settings content…</div>
<div id="panel-security" hidden>Security content…</div>
<div id="panel-billing" hidden>Billing content…</div>
<script>
new Roovix.Tabs('#settingsTabs', {
onChange: (index, tabEl) => {
const panelId = tabEl.getAttribute('data-panel');
document.querySelectorAll('[id^="panel-"]').forEach((p) => {
p.hidden = p.id !== 'panel-' + panelId;
});
},
});
</script>import { useState } from 'react';
const TABS = [
{ id: 'general', label: 'General' },
{ id: 'security', label: 'Security' },
{ id: 'billing', label: 'Billing' },
];
function SettingsTabs() {
const [active, setActive] = useState('general');
return (
<>
<div className="tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={`tab ${active === t.id ? 'active' : ''}`}
onClick={() => setActive(t.id)}
>
{t.label}
</button>
))}
</div>
{active === 'general' && <GeneralPanel />}
{active === 'security' && <SecurityPanel />}
{active === 'billing' && <BillingPanel />}
</>
);
}Avatars
<div class="avatar avatar-lg">AL</div>
<div class="avatar-group" id="memberAvatars"></div>
<script>
function initials(name) {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
members.forEach((m) => {
const el = document.createElement('div');
el.className = 'avatar';
el.textContent = initials(m.name);
document.getElementById('memberAvatars').appendChild(el);
});
</script>function initials(name) {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
function Avatar({ name, size = '' }) {
return (
<div className={`avatar ${size ? `avatar-${size}` : ''}`}>
{initials(name)}
</div>
);
}
function AvatarGroup({ users }) {
return (
<div className="avatar-group">
{users.map((u) => <Avatar key={u.id} name={u.name} />)}
</div>
);
}
<AvatarGroup users={project.members} />Loading
<div id="list">
<div class="spinner" id="listSpinner"></div>
</div>
<div class="progress">
<div class="bar" id="uploadBar" style="width:0%"></div>
</div>
<script>
async function loadItems() {
document.getElementById('listSpinner').style.display = 'inline-block';
const res = await fetch('/api/items');
const items = await res.json();
document.getElementById('listSpinner').style.display = 'none';
// render items…
}
function setProgress(pct) {
document.getElementById('uploadBar').style.width = pct + '%';
}
</script>function Loader({ size }) {
return <div className={`spinner ${size ? `spinner-${size}` : ''}`} />;
}
function Progress({ value, indeterminate }) {
return (
<div className={`progress ${indeterminate ? 'indeterminate' : ''}`}>
<div className="bar" style={indeterminate ? undefined : { width: `${value}%` }} />
</div>
);
}
// Usage
{loading ? <Loader /> : <ItemList items={items} />}
<Progress value={uploadPct} />Modal
Title, body, footer, and callbacks are all your props.
<button type="button" class="btn btn-danger" id="deleteProjectBtn">Delete project</button>
<script>
document.getElementById('deleteProjectBtn').addEventListener('click', () => {
const modal = new Roovix.Modal({
title: 'Delete project?',
content: '<p>This will permanently remove <strong>Acme Redesign</strong> and all of its data.</p>',
footer: `
<button type="button" class="btn btn-secondary" id="modalCancel">Cancel</button>
<button type="button" class="btn btn-danger" id="modalConfirm">Delete</button>
`,
onClose: () => console.log('modal closed'),
});
modal.open();
document.getElementById('modalCancel').onclick = () => modal.close();
document.getElementById('modalConfirm').onclick = async () => {
const btn = document.getElementById('modalConfirm');
btn.disabled = true;
btn.textContent = 'Deleting…';
await fetch('/api/projects/123', { method: 'DELETE' });
modal.close();
window.location.href = '/projects';
};
});
</script>import { useRef } from 'react';
import Roovix from 'roovix-ui';
function DeleteProjectButton({ projectId, projectName }) {
const modalRef = useRef(null);
const open = () => {
modalRef.current = new Roovix.Modal({
title: 'Delete project?',
content: `<p>This will permanently remove <strong>${projectName}</strong>.</p>`,
footer: `
<button type="button" class="btn btn-secondary" id="m-cancel">Cancel</button>
<button type="button" class="btn btn-danger" id="m-ok">Delete</button>
`,
});
modalRef.current.open();
document.getElementById('m-cancel').onclick = () => modalRef.current.close();
document.getElementById('m-ok').onclick = async () => {
await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
modalRef.current.close();
// navigate or refresh list
};
};
return (
<button type="button" className="btn btn-danger" onClick={open}>
Delete project
</button>
);
}Drawer
<button type="button" class="btn btn-secondary" id="filtersBtn">Filters</button>
<script>
const drawer = new Roovix.Drawer({
side: 'right',
title: 'Filters',
content: `
<label class="control"><input type="checkbox" name="status" value="active"/> Active</label>
<label class="control"><input type="checkbox" name="status" value="pending"/> Pending</label>
<button type="button" class="btn btn-primary btn-sm" id="applyFilters" style="margin-top:12px">Apply</button>
`,
});
document.getElementById('filtersBtn').onclick = () => drawer.open();
// After open, wire the apply button (content is in the DOM)
document.addEventListener('click', (e) => {
if (e.target.id === 'applyFilters') {
const checked = [...document.querySelectorAll('input[name="status"]:checked')]
.map((i) => i.value);
console.log('filters', checked);
drawer.close();
// reload list with ?status=active,pending
}
});
</script>import { useRef } from 'react';
import Roovix from 'roovix-ui';
function FiltersDrawer({ onApply }) {
const drawerRef = useRef(null);
const open = () => {
if (!drawerRef.current) {
drawerRef.current = new Roovix.Drawer({
side: 'right',
title: 'Filters',
content: `
<label class="control"><input type="checkbox" name="status" value="active"/> Active</label>
<label class="control"><input type="checkbox" name="status" value="pending"/> Pending</label>
<button type="button" class="btn btn-primary btn-sm" id="applyFilters">Apply</button>
`,
});
}
drawerRef.current.open();
setTimeout(() => {
document.getElementById('applyFilters').onclick = () => {
const values = [...document.querySelectorAll('input[name="status"]:checked')].map((i) => i.value);
onApply(values);
drawerRef.current.close();
};
}, 0);
};
return <button type="button" className="btn btn-secondary" onClick={open}>Filters</button>;
}Dropdown
Menu items are your content. Handle clicks to get the chosen action.
<div class="dropdown">
<button class="btn btn-secondary" id="rowMenuBtn" type="button">Actions ▾</button>
<div class="dropdown-menu" id="rowMenu">
<div class="dropdown-header">Actions</div>
<button class="dropdown-item" type="button" data-action="edit">Edit</button>
<button class="dropdown-item" type="button" data-action="duplicate">Duplicate</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" type="button" data-action="delete">Delete</button>
</div>
</div>
<script>
const rowId = 'proj_8f3a'; // from your row data
new Roovix.Dropdown('#rowMenuBtn', '#rowMenu');
document.getElementById('rowMenu').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action'); // 'edit' | 'duplicate' | 'delete'
if (action === 'edit') {
window.location.href = `/projects/${rowId}/edit`;
} else if (action === 'duplicate') {
await fetch(`/api/projects/${rowId}/duplicate`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`/api/projects/${rowId}`, { method: 'DELETE' });
}
});
</script>import { useEffect, useRef } from 'react';
import Roovix from 'roovix-ui';
function RowActions({ rowId, onDeleted }) {
const triggerRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
new Roovix.Dropdown(triggerRef.current, menuRef.current);
}, []);
const run = async (action) => {
if (action === 'edit') {
window.location.href = `/projects/${rowId}/edit`;
} else if (action === 'duplicate') {
await fetch(`/api/projects/${rowId}/duplicate`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`/api/projects/${rowId}`, { method: 'DELETE' });
onDeleted?.(rowId);
}
};
return (
<div className="dropdown">
<button ref={triggerRef} type="button" className="btn btn-secondary">Actions ▾</button>
<div ref={menuRef} className="dropdown-menu">
<div className="dropdown-header">Actions</div>
<button type="button" className="dropdown-item" onClick={() => run('edit')}>Edit</button>
<button type="button" className="dropdown-item" onClick={() => run('duplicate')}>Duplicate</button>
<div className="dropdown-divider" />
<button type="button" className="dropdown-item" onClick={() => run('delete')}>Delete</button>
</div>
</div>
);
}Tooltip
<span class="tooltip-trigger">
<button type="button" class="btn btn-ghost btn-icon" aria-label="Info">ℹ</button>
<span class="tooltip">API keys are shown once. Store them securely.</span>
</span>function Tooltip({ content, children }) {
return (
<span className="tooltip-trigger">
{children}
<span className="tooltip">{content}</span>
</span>
);
}
<Tooltip content="API keys are shown once. Store them securely.">
<button type="button" className="btn btn-ghost btn-icon">ℹ</button>
</Tooltip>File Upload
Width stays at your container width (default 100%). Long names truncate. Wire onUpload to your real API with FormData + progress.
<div id="uploader" style="width:100%">
<div class="drop-zone">
<div class="dz-icon">📁</div>
<div class="dz-text">Drop files or click to browse</div>
<div class="dz-hint">PNG, JPG, PDF · max 10 MB</div>
</div>
<div data-file-list></div>
<button type="button" class="btn btn-primary" id="uploadBtn">Upload to server</button>
</div>
<script>
const uploader = new Roovix.FileUpload('#uploader', {
width: '100%',
accept: 'image/*,application/pdf',
multiple: true,
maxSize: 10 * 1024 * 1024, // 10 MB
maxFiles: 10,
simulate: false, // important: use real onUpload
onAdd: (entry) => {
console.log('queued', entry.file.name, entry.file.size);
},
onUpload: async (files, { updateProgress, setStatus }) => {
// files = File[] from the queue
const uploaded = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const form = new FormData();
form.append('file', file);
form.append('folder', 'invoices'); // any extra fields your API needs
try {
// XMLHttpRequest gives upload progress events
const url = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads');
xhr.setRequestHeader('Authorization', 'Bearer ' + localStorage.getItem('token'));
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
updateProgress(i, pct); // drives the UI bar for this file
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const json = JSON.parse(xhr.responseText);
// e.g. { id: 'file_123', url: 'https://cdn.example.com/...' }
resolve(json);
} else {
setStatus(i, 'error');
reject(new Error(xhr.statusText || 'Upload failed'));
}
};
xhr.onerror = () => {
setStatus(i, 'error');
reject(new Error('Network error'));
};
xhr.send(form);
});
setStatus(i, 'done');
uploaded.push(url);
} catch (err) {
console.error(file.name, err);
setStatus(i, 'error');
}
}
return uploaded; // available to onComplete if you chain
},
onComplete: (entries) => {
const ok = entries.filter((e) => e.status === 'done');
console.log('finished', ok.length, 'files');
},
onError: (err) => {
alert(err.message || 'Upload error');
},
});
document.getElementById('uploadBtn').onclick = () => uploader.upload();
// Read files without uploading yet:
// const files = uploader.getFiles(); // File[]
</script>import { useEffect, useRef, useState } from 'react';
import Roovix from 'roovix-ui';
async function uploadFileToApi(file, onProgress, token) {
const form = new FormData();
form.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads');
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText));
else reject(new Error(xhr.statusText || 'Upload failed'));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(form);
});
}
export function InvoiceUploader({ authToken, onUploaded }) {
const ref = useRef(null);
const inst = useRef(null);
const [count, setCount] = useState(0);
useEffect(() => {
inst.current = new Roovix.FileUpload(ref.current, {
width: '100%',
accept: 'image/*,application/pdf',
multiple: true,
maxSize: 10 * 1024 * 1024,
simulate: false,
onAdd: () => setCount(inst.current.files.length),
onRemove: () => setCount(inst.current.files.length),
onUpload: async (files, { updateProgress, setStatus }) => {
const results = [];
for (let i = 0; i < files.length; i++) {
try {
const json = await uploadFileToApi(
files[i],
(pct) => updateProgress(i, pct),
authToken
);
setStatus(i, 'done');
results.push(json); // { id, url }
} catch {
setStatus(i, 'error');
}
}
onUploaded?.(results);
return results;
},
});
}, [authToken]);
return (
<div>
<div ref={ref} style={{ width: '100%' }}>
<div className="drop-zone">
<div className="dz-icon">📁</div>
<div className="dz-text">Drop invoices or click to browse</div>
<div className="dz-hint">PDF or image · max 10 MB</div>
</div>
<div data-file-list />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 12 }}>
<span className="text-sm text-muted">{count} file{count !== 1 ? 's' : ''}</span>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => inst.current.upload()}
disabled={count === 0}
>
Upload to server
</button>
</div>
</div>
);
}
// Parent
<InvoiceUploader
authToken={session.token}
onUploaded={(files) => {
// files = [{ id, url }, ...] from your API
setAttachments((prev) => [...prev, ...files]);
}}
/>Tables
Columns and rows always come from your data.
| Name | Status | Role |
|---|---|---|
| Alex Rivera | Active | Admin |
| Jordan Lee | Pending | Editor |
| Sam Chen | Inactive | Viewer |
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Status</th><th>Role</th></tr>
</thead>
<tbody id="userRows"></tbody>
</table>
</div>
<script>
const statusBadge = { active: 'badge-success', pending: 'badge-warning', inactive: 'badge-done' };
async function renderUsers() {
const users = await fetch('/api/users').then((r) => r.json());
document.getElementById('userRows').innerHTML = users.map((u) => `
<tr>
<td>${u.name}</td>
<td><span class="badge ${statusBadge[u.status]}">${u.status}</span></td>
<td>${u.role}</td>
</tr>
`).join('');
}
renderUsers();
</script>const STATUS_BADGE = {
active: 'badge-success',
pending: 'badge-warning',
inactive: 'badge-done',
};
function UsersTable({ users }) {
return (
<div className="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Status</th><th>Role</th></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>
<span className={`badge ${STATUS_BADGE[u.status]}`}>{u.status}</span>
</td>
<td>{u.role}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// Parent loads data
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then((r) => r.json()).then(setUsers);
}, []);
<UsersTable users={users} />Pagination
<div class="pagination" id="pager"></div>
<script>
let page = 1;
const totalPages = 12;
function renderPager() {
const el = document.getElementById('pager');
el.innerHTML = '';
const prev = document.createElement('button');
prev.className = 'page';
prev.textContent = '‹';
prev.disabled = page <= 1;
prev.onclick = () => { page--; loadPage(page); renderPager(); };
el.appendChild(prev);
for (let p = 1; p <= totalPages; p++) {
const btn = document.createElement('button');
btn.className = 'page' + (p === page ? ' active' : '');
btn.textContent = p;
btn.onclick = () => { page = p; loadPage(page); renderPager(); };
el.appendChild(btn);
}
}
async function loadPage(p) {
const res = await fetch(`/api/items?page=${p}&limit=20`);
const data = await res.json();
// render data.items
}
renderPager();
</script>function Pagination({ page, totalPages, onChange }) {
return (
<div className="pagination">
<button type="button" className="page" disabled={page <= 1}
onClick={() => onChange(page - 1)}>‹</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button key={p} type="button"
className={`page ${p === page ? 'active' : ''}`}
onClick={() => onChange(p)}>{p}</button>
))}
<button type="button" className="page" disabled={page >= totalPages}
onClick={() => onChange(page + 1)}>›</button>
</div>
);
}
function ItemList() {
const [page, setPage] = useState(1);
const [items, setItems] = useState([]);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetch(`/api/items?page=${page}&limit=20`)
.then((r) => r.json())
.then((d) => {
setItems(d.items);
setTotalPages(d.totalPages);
});
}, [page]);
return (
<>
<ItemTable rows={items} />
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
);
}