JORVEL

@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

ComponentPurpose
ButtonPrimary / secondary / ghost variants, sm/md/lg sizes
InputInline label, error message, sm/md/lg sizes
ModalDialog with ESC + overlay-click close, aria-modal, focus trap
ToastToastProvider + useToast, info/success/warn/error
CardOutline / elevated variants
ThemeProviderMaps a partial theme onto CSS variables consumed by every component

Button

tsx
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>
PropTypeDefault
variant'primary' | 'secondary' | 'ghost' | 'danger''primary'
size'sm' | 'md' | 'lg''md'
loadingbooleanfalse
fullWidthbooleanfalse
iconLeft / iconRightReactNode

Input

tsx
import { Input } from '@jorvel/ui';

<Input
  label="Email"
  type="email"
  value={email}
  onChange={(e) => setEmail(e.currentTarget.value)}
  error={emailError}
  size="md"
/>

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).

tsx
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.

tsx
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

TokenCSS variableDefault
colorPrimary--jorvel-color-primary#4f46e5
colorDanger--jorvel-color-danger#dc2626
colorBg--jorvel-color-bg#ffffff
colorText--jorvel-color-text#0f172a
radiusMd--jorvel-radius-md6px
shadowMd--jorvel-shadow-mdshorthand
fontFamily--jorvel-font-familysystem 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.

tsx
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.

tsx
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>
  );
}
ToastOptionsType
messageReactNode
variant'info' | 'success' | 'warn' | 'error'
durationnumber ms — 0 means manual dismiss only
actionLabelstring
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.

ts
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.json

SSR-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

Components stay small and CSS-variable-driven; we don't ship CSS files. Bring your own Tailwind / vanilla-extract / styled-components layer for production polish.