@jorvel/ui
Lean component primitives styled with CSS variables, no runtime dependencies, and a Storybook scaffolder for the full design-system experience. The package solves the "every team rewrites a Button" problem without locking you into a styling opinion — bring Tailwind, vanilla-extract, or styled-components on top.
Components
| Component | Purpose |
|---|---|
Button | Primary / secondary / ghost variants, sm/md/lg sizes |
Input | Inline label, error message, sm/md/lg sizes |
Modal | Dialog with ESC + overlay-click close, aria-modal, focus trap |
Toast | ToastProvider + useToast, info/success/warn/error |
Card | Outline / elevated variants |
ThemeProvider | Maps a partial theme onto CSS variables consumed by every component |
Button
import { Button } from '@jorvel/ui';
<Button variant="primary" size="md" onClick={save}>Save</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="ghost" disabled>Pending</Button>
<Button variant="primary" loading>Submitting…</Button>| Prop | Type | Default |
|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'danger' | 'primary' |
size | 'sm' | 'md' | 'lg' | 'md' |
loading | boolean | false |
fullWidth | boolean | false |
iconLeft / iconRight | ReactNode | — |
Input
import { Input } from '@jorvel/ui';
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
error={emailError}
size="md"
/>Modal
Renders into a portal at document.body. Traps focus while open, restores on close, blocks scroll on the body, dismisses on Escape or overlay click (toggle each separately).
import { Modal, Button } from '@jorvel/ui';
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Confirm delete"
size="md"
closeOnOverlayClick
closeOnEscape
>
<p>This cannot be undone.</p>
<Modal.Footer>
<Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</Modal.Footer>
</Modal>Theming
Wrap your tree with ThemeProvider; the provider emits CSS custom properties that every component reads. Override per key, fallback to defaults. The provider supports nesting — a ThemeProvider further down the tree overrides only the keys it sets.
import { ThemeProvider, Button, Card } from '@jorvel/ui';
export default function App() {
return (
<ThemeProvider theme={{ colorPrimary: '#0ea5e9', radiusMd: '10px' }}>
<Card variant="elevated">
<Button variant="primary">Save</Button>
</Card>
</ThemeProvider>
);
}Theme tokens
| Token | CSS variable | Default |
|---|---|---|
colorPrimary | --jorvel-color-primary | #4f46e5 |
colorDanger | --jorvel-color-danger | #dc2626 |
colorBg | --jorvel-color-bg | #ffffff |
colorText | --jorvel-color-text | #0f172a |
radiusMd | --jorvel-radius-md | 6px |
shadowMd | --jorvel-shadow-md | shorthand |
fontFamily | --jorvel-font-family | system stack |
Dark mode
Two themes, one provider — swap the entire object on a media query or user preference. Use the View Transitions API for a smooth crossfade.
import { ThemeProvider, defaultTheme } from '@jorvel/ui';
import { withViewTransition } from '@jorvel/runtime';
const darkTheme = { ...defaultTheme, colorBg: '#0f172a', colorText: '#f8fafc' };
function App({ mode }: { mode: 'light' | 'dark' }) {
return (
<ThemeProvider theme={mode === 'dark' ? darkTheme : defaultTheme}>
…
</ThemeProvider>
);
}
const toggle = () => withViewTransition(() => setMode((m) => m === 'dark' ? 'light' : 'dark'));Toasts
Wrap the app once with ToastProvider, then call useToast() anywhere. Toasts dismiss themselves after duration ms unless manually dismissed. Stacks bottom-right by default; configurable.
import { ToastProvider, useToast, Button } from '@jorvel/ui';
function Demo() {
const toast = useToast();
return (
<Button onClick={() => toast.push({ message: 'Saved', variant: 'success' })}>
Save
</Button>
);
}
export default function App() {
return (
<ToastProvider position="bottom-right" maxStack={5}>
<Demo />
</ToastProvider>
);
}| ToastOptions | Type |
|---|---|
message | ReactNode |
variant | 'info' | 'success' | 'warn' | 'error' |
duration | number ms — 0 means manual dismiss only |
actionLabel | string |
onAction | () => void |
Storybook scaffold
storybookFiles() returns a complete file list for a Storybook 8 setup pointed at libs/ui/src/**/*.stories.tsx. storybookScripts + storybookDevDeps give you the npm wiring; drop both into your workspace package.json and run pnpm storybook.
import fs from 'node:fs/promises';
import path from 'node:path';
import { storybookFiles, storybookScripts, storybookDevDeps } from '@jorvel/ui';
for (const f of storybookFiles()) {
await fs.mkdir(path.dirname(f.path), { recursive: true });
await fs.writeFile(f.path, f.contents, 'utf8');
}
// then merge storybookScripts + storybookDevDeps into your package.jsonSSR-safe by default
Every component renders correctly on the server. Modal and Toast skip portal mounting until client mount via useEffect, so the SSR output is identical to the first client frame — no hydration warnings.
Headless-first
