RoovixRoovix UIv1.0

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
npm install roovix-ui
Import
<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.

Current: dark
<!-- 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

--color-canvas-default
--color-canvas-inset
--color-canvas-subtle
--color-canvas-overlay
--color-canvas-elevated

Foreground

--color-fg-default
--color-fg-muted
--color-fg-subtle
--color-fg-on-emphasis

Semantic

accent-fg
accent-emphasis
success-fg
success-emphasis
warning-fg
warning-emphasis
danger-fg
danger-emphasis
info-fg
info-emphasis
done-fg
done-emphasis
Real usage — apply tokens in CSS / HTML / React
Page surface
Secondary text uses muted foreground
Accent link color · Success · Danger
<!-- 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.

Page title
Section heading
Subsection
Body — default foreground
Muted — secondary copy
Subtle — hints & meta
Monospace — code & tokens
<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

xs
sm
md
lg
xl
<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

subtle
muted
default
hard
accent
success
danger
<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>
  );
}

Buttons

Variants, sizes, disabled state. Label and click handler come from your app.

<button type="button" class="btn btn-primary" id="saveBtn">Save changes</button>
<button type="button" class="btn btn-secondary">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="deleteBtn">Delete</button>
<button type="button" class="btn btn-primary" disabled>Saving…</button>

<script>
  document.getElementById('saveBtn').addEventListener('click', async () => {
    const btn = document.getElementById('saveBtn');
    btn.disabled = true;
    btn.textContent = 'Saving…';
    try {
      await fetch('/api/settings', { method: 'POST', body: JSON.stringify(formData) });
      btn.textContent = 'Saved';
    } catch (err) {
      btn.textContent = 'Retry';
      btn.disabled = false;
    }
  });
</script>
import { useState } from 'react';

function Button({ variant = 'primary', size, disabled, loading, children, onClick, ...props }) {
  const cls = ['btn', `btn-${variant}`, size && `btn-${size}`].filter(Boolean).join(' ');
  return (
    <button type="button" className={cls} disabled={disabled || loading} onClick={onClick} {...props}>
      {loading ? 'Please wait…' : children}
    </button>
  );
}

function SaveButton({ payload }) {
  const [loading, setLoading] = useState(false);
  const handleSave = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/settings', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!res.ok) throw new Error('Save failed');
    } finally {
      setLoading(false);
    }
  };
  return (
    <Button variant="primary" loading={loading} onClick={handleSave}>
      Save changes
    </Button>
  );
}

Badges & Tags

Accent Active Pending Failed Beta Archived design v2.0 shipped breaking
<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.

https://
<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.

Free — $0/mo
Pro — $29/mo
Team — $99/mo
Selected:
<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

Card title
Supporting text from your data.
12.4k
Active users
↑ 12%
98.2%
Uptime
↓ 0.3%
<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

New API version available. Review the migration guide.
Payment method updated successfully.
Your trial ends in 3 days.
Could not reach the server. Try again.
Saved
Your changes have been stored.
<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.

Active: Overview
<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

A
B
C
R
O
X
<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} />

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>;
}

Tooltip

Explains this control
<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.

📁
Drop files here or click to browse
Images & PDF · max 10 MB each
0 files
<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.

NameStatusRole
Alex RiveraActiveAdmin
Jordan LeePendingEditor
Sam ChenInactiveViewer
<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} />
    </>
  );
}