30 typed components and one design-token palette, built for agentic code workflows. UIKit ships the pieces and the tokens; your app owns its own chrome. Tailwind's default palette is removed, so bg-blue-600 compiles to nothing and bg-primary works — which is what keeps a generated codebase looking like one product instead of thirty. Machine-readable specs (llms.txt + AGENTS.md) ship in the package so agents generate correct TSX first time.
npm install, every componentnpm install @bloomneo/uikit
This page is written for humans. Agents should fetch /uikit/llms.txt and /uikit/AGENTS.md as the authoritative specs — every component's exact prop shape, canonical setup, and what NOT to do. Same files ship inside the published npm package under node_modules/@bloomneo/uikit/ and are regenerated on every release.
4.0 removed every layout component. Not because they were broken — because every real application replaced them within weeks. App chrome is where product identity lives, and a generic sidebar is the first thing anyone rewrites. What UIKit ships is the part nobody wants to rebuild.
PageHeader, DataTable, EmptyState, ConfirmDialog — the pieces every internal tool rebuilds by hand.PermissionGate fails closed, plus role-aware menus and gated buttons.Form, FormField, Select, Combobox with a11y wiring and error display baked in.Command palette, Dialog, HoverCard, streaming-friendly typography.cn() helper, and uikit generate theme. Fork the design system without forking the components.This is the pattern two production apps arrived at independently, by hand, after abandoning the shipped AdminLayout. It is now the recommended shape. The shell is a route, so it mounts once and survives navigation — sidebar scroll position, open menus and any providers stay put.
// AdminLayoutRoute.tsx — the shell, written once, in your app
export function AdminLayoutRoute() {
return (
<div className="flex min-h-screen">
<Sidebar /> {/* yours */}
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
);
}
// Any page — owns its header, returns plain content
import { PageHeader, Button } from '@bloomneo/uikit';
export default function UsersPage() {
return (
<div className="space-y-6">
<PageHeader title="Users" actions={<Button>Invite</Button>} />
<UsersTable />
</div>
);
}
PageHeader survived 4.0 precisely because it is the replacement for the title and breadcrumbs props the layouts used to own.
These are the only ways to silently break UIKit code. Memorise them — or point your AI agent at AGENTS.md where they're spelled out line by line.
onChange on <Select> or <Combobox>. Both use onValueChange(newValue). Using onChange fails silently — no error, no update. onChange is reserved for native input wrappers (Input, Textarea, PasswordInput).undefined to <DataTable data>. Always fall back to [] during loading: data={users ?? []}. Passing undefined throws UIKitError.<ToastProvider> or <ConfirmProvider> twice. Duplicate mounts produce doubled behaviour in prod and fire a dev-only warning. Exactly one per app.<FormField>. Bare <Label> + <Input> misses error display and a11y wiring. <FormField label="..." error={...}> handles both.bg-primary, text-muted-foreground, border-border). Since 3.0 this one is not advice — the raw palette is removed, so bg-blue-500 compiles to nothing and the element renders unstyled. You find out immediately, not six months later.Four handler families. Picking the right one is the single biggest thing agents and humans get wrong. Memorise this table.
| Component family | Value prop | Change handler |
|---|---|---|
Native inputs — Input, Textarea, PasswordInput | value | onChange(e) — ChangeEvent |
Radix pickers — Select, Combobox, Slider, Tabs, Accordion | value | onValueChange(newValue) |
Radix checkables — Checkbox, Switch, RadioGroup | checked | onCheckedChange(checked) |
Overlays — Dialog, Sheet, Popover | open | onOpenChange(open) |
In 2.0, <Combobox> switched from onChange to onValueChange to match <Select>. No alias kept — this is drift-checked in CI.
// 1. Wire the styles. If your app runs its own Tailwind — the normal case —
// add this to your CSS instead of importing a stylesheet:
//
// @import "tailwindcss";
// @import "@bloomneo/uikit/theme";
//
// Only if your app ships no build, import the prebuilt sheet at the entry:
import '@bloomneo/uikit/styles';
// 2. Wrap your app with providers
import { ThemeProvider, ToastProvider, ConfirmProvider } from '@bloomneo/uikit';
<ThemeProvider theme="base" mode="light">
<ToastProvider position="bottom-right" />
<ConfirmProvider>
<App />
</ConfirmProvider>
</ThemeProvider>
// 3. Add FOUC prevention in index.html <head> — prevents theme flash before React mounts
import { foucScript } from '@bloomneo/uikit/fouc';
<script dangerouslySetInnerHTML={{ __html: foucScript() }} />
There is exactly ONE supported import path for normal use. Always use the flat import:
import {
Button, Input, Card, CardHeader, CardTitle, CardContent,
Alert, AlertDescription, Badge, DataTable, Dialog,
DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, Combobox, Sheet, Tooltip, EmptyState,
PageHeader, PermissionGate, PermissionProvider,
FormField, PasswordInput, toast, useToast, useConfirm,
useTheme, useApi, usePagination, useBreakpoint,
formatBytes, formatCurrency, formatDate, timeAgo,
} from '@bloomneo/uikit';
// Deep imports like '@bloomneo/uikit/button' exist for tree-shaking
// but are NOT the canonical form. Don't mix styles in one file.
A design system that is merely documented gets bypassed. One production app accumulated 1,547 hardcoded palette classes against 196 semantic ones — not carelessness, but because both were equally available and the raw palette needed no lookup. Removing the alternative is the only version of the rule that holds.
/* your app's index.css */
@import "tailwindcss";
@import "@bloomneo/uikit/theme"; /* tokens + the palette lockdown */
This import matters more than it looks. A prebuilt stylesheet cannot constrain your build: if your app runs its own @import "tailwindcss", your Tailwind generates whatever utilities your source uses, bg-blue-600 included, no matter what UIKit ships. The reset only takes effect when it participates in the build that scans your code — which is why /theme is shipped as source rather than compiled CSS.
| Class | /theme & /styles | /styles/permissive |
|---|---|---|
bg-primary, text-muted-foreground, bg-card | ✅ | ✅ |
bg-success, bg-warning, bg-contrast | ✅ | ✅ |
bg-blue-600, text-gray-900, border-red-400 | 🚫 compiles to nothing | ✅ 2.x behaviour |
/styles/permissive is a migration aid with an end date, not a supported mode.
4.0 removed the elegant, metro, studio and vivid presets — four more palettes to keep consistent, and near-zero projects switched to them. 4.1 removed the 3.4 MB of typefaces that served them. What remains covers the real case: one brand palette per product.
npx uikit generate theme brand # scaffold the preset
npx uikit bundle # compile it to CSS
A theme is just a class that redefines the token custom properties. Every UIKit utility already resolves through var(--color-…), so this re-skins all 30 components at once — no component import changes:
.theme-ops {
--color-primary: #8B5CF6;
--color-background: #0B0A12;
--color-chart1: #A78BFA; --color-chart2: #22D3EE;
--color-chart3: #34D399; --color-chart4: #FBBF24; --color-chart5: #F43F5E;
--color-radius: 0.375rem;
}
Switch at runtime with useTheme().setTheme('ops'). Setting the class on <html> by hand does not work — ThemeProvider strips any theme-* class it did not set. The Theme type is 'base' | (string & {}) so generated ids type-check.
import { Button } from '@bloomneo/uikit';
// Variants
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
// States
<Button disabled>Disabled</Button>
<Button loading>Saving...</Button>
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '@bloomneo/uikit';
<Card>
<CardHeader>
<CardTitle>User Settings</CardTitle>
<CardDescription>Manage your account preferences</CardDescription>
</CardHeader>
<CardContent>
<p>Content goes here</p>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>
Sortable, filterable, paginated table with row actions. Define columns once and let the table handle the rest.
import { DataTable, type DataTableColumn, type RowAction } from '@bloomneo/uikit';
import { Pencil, Trash2 } from 'lucide-react';
type User = { id: string; name: string; email: string; role: 'admin' | 'user'; createdAt: string; };
const columns: DataTableColumn<User>[] = [
{ id: 'name', header: 'Name', accessorKey: 'name', sortable: true },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{ id: 'role', header: 'Role', accessorKey: 'role', sortable: true },
{ id: 'createdAt', header: 'Joined', accessorKey: 'createdAt', sortable: true, dataType: 'date' },
];
const actions: RowAction<User>[] = [
{ id: 'edit', label: 'Edit', icon: <Pencil size={14} />, onClick: (row) => handleEdit(row) },
{ id: 'delete', label: 'Delete', icon: <Trash2 size={14} />, onClick: (row) => handleDelete(row), destructive: true },
];
<DataTable<User>
data={users}
columns={columns}
rowActions={actions}
searchable
pagination
pageSize={10}
getRowId={(row) => row.id}
/>
import { FormField, Input, PasswordInput, Select, Combobox, Button } from '@bloomneo/uikit';
// FormField wraps any input with label, error, and helper text
<FormField label="Email" required error={emailError} helper="We'll never share this">
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</FormField>
<FormField label="Password" required>
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} />
</FormField>
// Searchable select with async loading
<Combobox
value={country}
onValueChange={setCountry}
options={countries}
placeholder="Select a country"
searchPlaceholder="Search countries..."
clearable
/>
Mount <ToastProvider /> once at the root. Call toast.* from anywhere — no hooks, no context drilling.
import { toast } from '@bloomneo/uikit';
// Simple toasts
toast.success('Changes saved');
toast.error('Something went wrong');
toast.info('Heads up — deployment is running');
toast.warning('Your session expires in 5 minutes');
// With description and action
toast('Saved', {
description: 'Your changes are now live',
action: {
label: 'Undo',
onClick: () => toast.info('Undone'),
},
});
useConfirm() returns a promise — true if confirmed, false if cancelled. No boilerplate modal state needed.
import { useConfirm } from '@bloomneo/uikit';
function DeleteButton() {
const confirm = useConfirm();
async function handleDelete() {
const ok = await confirm({
title: 'Delete this record?',
description: 'This cannot be undone.',
confirmLabel: 'Delete',
tone: 'destructive',
});
if (!ok) return;
await deleteRecord();
toast.success('Deleted');
}
// High-stakes: user must type a phrase before confirm enables
async function handleHardDelete() {
const ok = await confirm.destructive({
title: 'Delete account',
description: 'This will permanently delete the account and all its data.',
verifyText: 'delete my account',
});
if (ok) await nukeAccount();
}
return <Button variant="destructive" onClick={handleDelete}>Delete</Button>;
}
import { useState } from 'react';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@bloomneo/uikit';
export function EditProfileDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Edit profile</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Make changes and save when you're done.</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">Form fields go here.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => setOpen(false)}>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
import { Users } from 'lucide-react';
import { Button, PageHeader } from '@bloomneo/uikit';
<PageHeader
icon={<Users />}
title="User management"
description="View and manage all users in your workspace"
breadcrumbs={[
{ label: 'Admin', href: '/admin' },
{ label: 'Users' },
]}
actions={<Button>Add user</Button>}
/>
import { Inbox } from 'lucide-react';
import { Button, EmptyState } from '@bloomneo/uikit';
<EmptyState
icon={<Inbox />}
title="No invoices yet"
description="Create your first invoice to get started."
action={<Button onClick={handleCreate}>Create invoice</Button>}
/>
import { PermissionGate, PermissionProvider, Button } from '@bloomneo/uikit';
// Bring your own auth source — just provide a check function
const check = (perm: string) => currentUser.roles.includes(perm);
<PermissionProvider check={check}>
{/* Single permission */}
<PermissionGate when="admin">
<Button variant="destructive">Delete user (admin only)</Button>
</PermissionGate>
{/* OR logic across multiple roles */}
<PermissionGate when={['admin', 'moderator']} fallback={<span>Restricted</span>}>
<Button>Moderate content</Button>
</PermissionGate>
{/* Custom predicate */}
<PermissionGate when={() => currentUser.plan === 'pro'}>
<ProFeature />
</PermissionGate>
</PermissionProvider>
import { formatBytes, formatCurrency, formatDate, timeAgo, Time } from '@bloomneo/uikit';
formatCurrency(1234.56, { currency: 'USD' }) // '$1,234.56'
formatCurrency(1234.56, { currency: 'INR', locale: 'en-IN' }) // '₹1,234.56'
formatDate(new Date(), { preset: 'long' }) // 'April 12, 2026'
timeAgo(new Date(Date.now() - 10 * 60 * 1000)) // '10 minutes ago'
formatBytes(1_572_864) // '1.5 MB'
// Auto-updating relative time component
<Time date={publishedAt} />
import { useApi, useTheme, usePagination, useBreakpoint, useActiveBreakpoint, useMediaQuery } from '@bloomneo/uikit';
// API hook — managed loading/data/error state
const api = useApi({ baseURL: 'http://localhost:3000' });
await api.get('/api/users');
// api.data, api.loading, api.error available reactively
// Theme hook
const { theme, mode, setTheme, toggleMode, availableThemes } = useTheme();
// Pagination hook — works with any data source
const pagination = usePagination({ total: 234, pageSize: 10 });
const visible = allItems.slice(pagination.startIndex, pagination.endIndex);
// pagination.page, pagination.pageCount, pagination.hasPrev, pagination.hasNext
// pagination.prev(), pagination.next(), pagination.goTo(n), pagination.pages
// Responsive breakpoints
const isAtLeastMd = useBreakpoint('md'); // true when ≥ 768px
const isMobile = useBreakpoint('md', 'down'); // true when < 768px
const active = useActiveBreakpoint(); // 'sm' | 'md' | 'lg' | 'xl' | '2xl'
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');
4.0 cut the export surface from 197 to 139 by removing the layouts, the sections, and fourteen primitives no shipped application had imported. What is left is what internal tools actually rebuild by hand.
Button / Input / Textarea / LabelThe basics, with consistent focus rings and disabled statesPasswordInputPassword field with a show/hide toggleSelect / ComboboxStatic option lists, and searchable/clearable pickers built on Command + PopoverCheckbox / RadioGroup / SwitchBoolean and choice inputs — all onCheckedChangeForm / FormFieldreact-hook-form + Zod wiring, plus the label / required marker / error / helper wrapperDataTableSortable, searchable, paginated, with row actions and typed column definitionsTableThe raw primitives, for when a page wants control of every cellPageHeaderTitle, description, breadcrumbs, actions — what a page owns instead of a layoutEmptyStateNo-results and first-run states, with role="status"Card / Badge / Alert / TabsPresentation and groupingDialog / Sheet / Popover / HoverCard / TooltipOverlays, all controlled with open + onOpenChangeConfirmDialog / useConfirmPromise-based confirmation — await confirm({ title })Toast / ToasterSonner-backed notifications via the toast.* APICommand / CommandDialog⌘K palettes, built on cmdkDropdownMenuAction menus, with checkbox, radio and submenu itemsPermissionGateRole-gated UI that fails closed, with a fallback slotTimeAuto-updating relative timestampRemoved in 4.0: AdminLayout, PageLayout, AuthLayout, BlankLayout, PopupLayout, MobileLayout, Header, Footer, Container, SafeArea, TabBar, Skeleton, Separator, Avatar, Progress, Accordion, Breadcrumb, Calendar, Collapsible, Menubar, Pagination, Slider, Toggle, Motion, DetailPage. For a loading placeholder, <div className="h-40 animate-pulse rounded-md bg-muted" /> is one line and stays on the palette.
A complete user list with search, sort, row actions, and delete-with-confirm:
import { useState } from 'react';
import { Pencil, Trash2, Users } from 'lucide-react';
import {
Button, ConfirmProvider, DataTable, PageHeader,
ThemeProvider, ToastProvider, toast, useConfirm,
type DataTableColumn, type RowAction,
} from '@bloomneo/uikit';
type User = { id: string; name: string; email: string; role: 'admin' | 'user' };
function UserListPage() {
const [users, setUsers] = useState<User[]>(initialUsers);
const confirm = useConfirm();
const columns: DataTableColumn<User>[] = [
{ id: 'name', header: 'Name', accessorKey: 'name', sortable: true },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{ id: 'role', header: 'Role', accessorKey: 'role', sortable: true },
];
const actions: RowAction<User>[] = [
{ id: 'edit', label: 'Edit', icon: <Pencil size={14} />, onClick: handleEdit },
{
id: 'delete', label: 'Delete', icon: <Trash2 size={14} />, destructive: true,
onClick: async (user) => {
const ok = await confirm({ title: `Delete ${user.name}?`, tone: 'destructive' });
if (!ok) return;
setUsers(prev => prev.filter(u => u.id !== user.id));
toast.success(`${user.name} deleted`);
},
},
];
return (
<>
<PageHeader icon={<Users />} title="Users" actions={<Button>Add user</Button>} />
<DataTable data={users} columns={columns} rowActions={actions} searchable pagination getRowId={r => r.id} />
</>
);
}
export default function App() {
return (
<ThemeProvider theme="base" mode="light">
<ToastProvider position="bottom-right" />
<ConfirmProvider>
<UserListPage />
</ConfirmProvider>
</ThemeProvider>
);
}
Vite + React. Full component library with all themes. SSR-compatible.
Electron renderer process. Same components, same themes, same API.
Capacitor + React. The components are responsive; the mobile shell (tab bar, safe areas) is yours to write.
Browser extension popup. Constrain the viewport in your own popup shell.
llms.txt and AGENTS.md machine-readable specs in every release, and typed props so a wrong one fails to compile. It is a library, not a framework: it deliberately ships no layouts and does not scaffold applications — that is @bloomneo/bloom's job.
@llm-rule JSDoc so agents pick the correct API first time.
src/components/ui/*.tsx ship the "use client" directive and work in the App Router. Server Components can import non-interactive primitives like Card, Badge, Alert directly. SSR and FOUC are handled via the foucScript() helper for <head>.
tailwind.config.js in your app (the bundled stylesheet is self-contained), but you do need to import @bloomneo/uikit/styles.
https://dev.bloomneo.com/uikit/llms.txt and /uikit/AGENTS.md — authoritative machine-readable specs. (2) Install the package; the same files ship inside node_modules/@bloomneo/uikit/. (3) Claude Code users: copy skills/bloomneo-uikit/ into your repo's .claude/skills/ for auto-triggered per-component guidance.
any in the public surface — DataTable<User>, RowAction<User>, formatters and hooks all infer correctly so agent autocomplete actually works. A drift-checked public-surface test in CI prevents accidental API changes.
base, in light and dark. 4.0 removed elegant, metro, studio and vivid — four more palettes to keep consistent, and near-zero projects switched to them; 4.1 removed the 3.4 MB of typefaces that served them. Custom themes are the supported path and are first-class: npx uikit generate theme <name> scaffolds one with OKLCH tokens and a dark variant, then useTheme().setTheme('<name>') switches at runtime. A theme is just a class redefining the --color-* properties, so it re-skins all 30 components at once.
isTauri(), isNative(), isBrowser(), detectPlatform()). The components are responsive and work in Electron, Capacitor and extension popups from one codebase. The shell for each target is yours to write — 4.0 removed the bundled MobileLayout and PopupLayout because every real app replaced them.
UIKitError subclasses — DataTableError, FormFieldError, ThemeError, etc.), docs-URL links in error messages, and a drift-checked public surface enforced in CI. The unified controlled-prop convention means agents never silently generate broken handlers.