@maholan/ui
v1.6.0
Published
The single distributable UI component library for the MHL UI Platform. Built on React Aria, styled with Tailwind CSS v4, following a clean, modern design language.
Readme
@maholan/ui
The single distributable UI component library for the MHL UI Platform. Built on React Aria, styled with Tailwind CSS v4, following a clean, modern design language.
Features
- Accessible by default — every interactive component wraps a
react-aria-componentsprimitive; no hand-rolled ARIA or keyboard handling - Next.js App Router ready — all interactive components carry
"use client"; purely presentational components are Server Component safe - Type-friendly — zero
any,VariantPropsre-exported so consumers can type their own wrappers - Easy to use — sensible defaults, consistent prop names (
size,color,isDisabled,iconLeading,iconTrailing) across all components - Flexible —
classNameon every component, React Aria render props exposed, icon slots accept anyReact.ReactNodeor FC - Design-led — variants and sizes follow the Untitled UI design system
Installation
Option A — npm package (managed dependency)
Install the library as a versioned dependency. Components are imported directly — no copying required.
# pnpm (recommended)
pnpm add @maholan/ui @maholan/tokens react react-dom
# npm
npm install @maholan/ui @maholan/tokens react react-dom
# yarn
yarn add @maholan/ui @maholan/tokens react react-dom
# bun
bun add @maholan/ui @maholan/tokens react react-domStep 1 — Add CSS imports to your global stylesheet (globals.css for
Next.js, index.css for Vite):
@import "tailwindcss";
@import "@maholan/tokens/mhl-tokens.css"; /* design token CSS variables */
@import "@maholan/ui/styles.css"; /* pre-built component utility classes */Why
@maholan/ui/styles.css? Tailwind v4 does not scannode_modulesby default.styles.cssis a pre-built stylesheet that contains every Tailwind utility class used by the components. Without it, components render unstyled.
Step 2 — Use components
// app/page.tsx — Server Component, no "use client" needed here
import { Button } from "@maholan/ui";
export default function Page() {
return (
<main>
<Button color="primary" size="md">
Get started
</Button>
</main>
);
}Option B — CLI registry (copy source into your project)
Copy component source directly into your project — you own the code and can modify it freely.
Step 1 — Initialize
npx @maholan/cli init
# or: pnpm dlx @maholan/cli init
# or: yarn dlx @maholan/cli init
# or: bunx @maholan/cli initThe init command will:
- Detect your framework (Next.js App Router, Pages Router, Vite, Remix)
- Prompt for component install path (default:
src/components/ui) - Create
mhl.config.json - Install base dependencies (
react-aria-components,class-variance-authority,clsx,tailwind-merge) - Copy
cn()utility to your project - Fetch
mhl-tokens.cssand inject@importinto your global CSS
Step 2 — Add components
# Add a single component
npx @maholan/cli add button
# Add multiple at once
npx @maholan/cli add button input checkbox
# Interactive picker
npx @maholan/cli add
# Also copy Storybook stories
npx @maholan/cli add button --with-storiesFiles are copied to src/components/ui/ — Tailwind scans your src/ natively,
no extra CSS import needed.
Step 3 — Keep tokens current
npx @maholan/cli sync # update mhl-tokens.css to latest registry version
npx @maholan/cli sync --check # CI enforcement — exits 1 if outdatedQuick Start (Next.js App Router)
// app/globals.css
@import "tailwindcss";
@import "@maholan/tokens/mhl-tokens.css";
@import "@maholan/ui/styles.css";// app/layout.tsx
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}// app/page.tsx
import { Button, TextField, Badge } from "@maholan/ui";
export default function Page() {
return (
<main className="p-8 flex flex-col gap-4">
<Badge color="brand" style="soft">
New
</Badge>
<TextField label="Email" placeholder="[email protected]" type="email" />
<Button color="primary" size="lg">
Get started
</Button>
</main>
);
}Components
Buttons
| Component | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Button | Primary interactive button — 9 color variants, 5 sizes, icon slots, loading state, renders as <a> when href is provided |
| ButtonGroup | Groups multiple buttons into a connected horizontal or vertical set |
| ButtonUtility | Icon-only utility button for toolbar actions |
| CloseButton | Dismiss / close action with pre-configured icon and ARIA label |
Button color variants: primary · secondary · tertiary · link-gray ·
link-color · primary-destructive · secondary-destructive ·
tertiary-destructive · link-destructive
Sizes (all components): sm (36px) · md (40px) · lg (44px) · xl
(48px) · 2xl (60px)
import { Button } from "@maholan/ui";
import { PlusIcon, ArrowRightIcon } from "lucide-react";
// Standard button
<Button color="primary" size="md">Save changes</Button>
// Link button — renders as <a>
<Button href="/dashboard" color="link-color">Go to dashboard</Button>
// With icon slots — pass FC (receives className) or ReactNode
<Button color="primary" iconLeading={PlusIcon}>Add item</Button>
<Button color="secondary" iconTrailing={<ArrowRightIcon className="size-4" />}>Continue</Button>
// Loading state
<Button color="primary" isLoading>Saving…</Button>
// Disabled
<Button color="primary" isDisabled>Unavailable</Button>
// Full width
<Button color="primary" fullWidth>Submit</Button>Input
| Component | Description |
| --------------------- | ----------------------------------------------------------------------- |
| TextField | Full text field with Label, Input, and HintText composed together |
| Input / InputBase | Raw input element — use inside custom field layouts |
| InputGroup | Input with leading/trailing button or dropdown addons |
| InputPassword | Password field with show/hide toggle |
| InputNumber | Numeric input with increment/decrement buttons |
| PaymentInput | Card number field with automatic brand detection |
| InputFile | File picker trigger |
| InputTags | Tag chip input — type and press Enter to add tags |
| InputTagsOuter | Tag input with chips rendered outside the field |
| PinInput | OTP / PIN entry with individual digit slots |
| Label | Form label with optional required indicator |
| HintText | Helper or error text displayed beneath an input |
import { TextField, HintText } from "@maholan/ui";
// Full field with label, hint, and error state
<TextField
label="Email address"
placeholder="[email protected]"
type="email"
isRequired
/>
<TextField
label="Password"
isInvalid
errorMessage="Must be at least 8 characters"
/>Badges
| Component | Description |
| ----------------- | ---------------------------------------------------------- |
| Badge | Inline label — 5 colors × 3 styles (filled, outline, soft) |
| BadgeWithDot | Badge with a color dot indicator |
| BadgeWithIcon | Badge with a leading icon |
| BadgeWithButton | Badge with an inline dismiss button |
| BadgeGroup | Stacked / grouped badge list |
Avatar
| Component | Description |
| ------------- | --------------------------------------------------------------------------- |
| Avatar | User or entity avatar — photo, initials fallback, company icon, status ring |
| AvatarCount | Overflow count badge (e.g. +5) for stacked groups |
| ProfilePack | Stacked avatar group with overflow count |
Select
| Component | Description |
| -------------- | ---------------------------------------------------- |
| Select | Single-select dropdown with popover and keyboard nav |
| MultiSelect | Multi-select with tag chip display |
| TagSelect | Tag-based multi-select with inline tag input |
| NativeSelect | Native <select> element with CVA variants |
Form controls
| Component | Description |
| ---------------------------- | ---------------------------------------------------------------------- |
| Checkbox / CheckboxBase | Checkbox with label, description, indeterminate state, and error state |
| RadioButton / RadioGroup | Radio button group — vertical or horizontal layout |
| Slider | Range slider with step control |
| Toggle / ToggleBase | Toggle switch with label and hint text |
Tags
| Component | Description |
| ---------------------- | ---------------------------------------- |
| Tag | Dismissible tag chip |
| TagCheckbox | Selectable tag chip (checkbox behaviour) |
| TagCloseX | Tag with close / remove action |
| TagGroup / TagList | Container for grouped tag collections |
Other
| Component | Description |
| --------------------------- | ----------------------------------------------------------------------- |
| Dropdown | Accessible menu with groups, icons, destructive item, full keyboard nav |
| TextArea / TextAreaBase | Auto-resize textarea with character count and error state |
| ProgressBar | Linear progress bar with label and tooltip |
| CircleProgressBar | Simple circular progress indicator |
| ProgressBarCircle | Full/half circle progress with label |
Variant Props
Every component re-exports its VariantProps type so you can type wrappers
without duplicating the union:
import { Button, type ButtonVariantProps } from "@maholan/ui";
interface MyButtonProps extends ButtonVariantProps {
label: string;
}
function MyButton({ label, color, size }: MyButtonProps) {
return (
<Button color={color} size={size}>
{label}
</Button>
);
}Styling Override
Pass className to any component to add or override classes. Uses cn()
(clsx + tailwind-merge) internally — no specificity conflicts:
<Button color="primary" className="w-full rounded-full">
Full-width pill button
</Button>React Aria Render Props
All interactive components expose React Aria's render props. Use them to apply conditional styles:
<Button
color="secondary"
className={(renderProps) =>
cn(
"base-class",
renderProps.isFocusVisible && "ring-4 ring-blue-500",
renderProps.isPressed && "opacity-80"
)
}
>
Custom states
</Button>Icon Slots
Components that accept icons use iconLeading and iconTrailing. Pass either a
React FC (receives className automatically for consistent sizing) or any
ReactNode. Not locked to any specific icon library:
import { SearchIcon } from "lucide-react";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// FC — className is injected automatically
<Button iconLeading={SearchIcon}>Search</Button>
// ReactNode — rendered as-is
<Button iconLeading={<MagnifyingGlassIcon className="size-4" />}>Search</Button>
// Trailing icon
<Button iconTrailing={ArrowRightIcon} color="link-color">Continue</Button>Package Exports
| Import | Contents |
| ------------------------ | -------------------------------------------------------- |
| @maholan/ui | All components, types, cn() utility |
| @maholan/ui/styles.css | Pre-built Tailwind utility classes for all components |
| @maholan/ui/stories | All Storybook stories (for master Storybook aggregation) |
| @maholan/ui/storybook | Storybook decorators, viewports, and theme config |
Component File Structure
Each component lives in src/components/base/<group>/<name>/:
src/components/base/buttons/button/
├── button.tsx # Component implementation ("use client")
├── button.variants.ts # CVA variant definitions
├── button.stories.tsx # Storybook stories (autodocs + per-variant)
├── button.test.tsx # Vitest + jest-axe tests
└── index.ts # Barrel exportDevelopment
# Build the package
pnpm build
# Watch mode
pnpm dev
# Run tests
pnpm test
# Type check
pnpm type-check
# Start Storybook
pnpm storybookRelated Packages
@maholan/tokens— Design tokens andmhl-tokens.css@maholan/theme—ThemeProvider,ThemeScript,useTheme@maholan/cli— CLI to copy component source into consumer projects@maholan/registry— Registry JSON builder
License
MIT
/* globals.css */
@import "tailwindcss";
@import "@maholan/tokens/mhl-tokens.css"; /* token variables */
@import "@maholan/ui/styles.css"; /* pre-built component utilities */import { Button, ButtonUtility, CloseButton } from "@maholan/ui";Why
@maholan/ui/styles.css? Tailwind v4 does not scannode_modulesby default.styles.cssis a pre-built stylesheet generated at build time from all component source files — it contains every utility class the components use. Without it, components render unstyled.
Option B — CLI (copy source into your project)
npx @maholan/cli init # copies mhl-tokens.css locally, injects @import
npx @maholan/cli add buttonFiles are copied to src/components/ui/ — you own and can modify the code.
Tailwind scans your src/ natively — no extra CSS import needed.
To keep the local token file current:
npx @maholan/cli sync # update mhl-tokens.css to latest
npx @maholan/cli sync --check # CI check — exits 1 if outdatedQuick Start
// app/page.tsx — Server Component, no "use client" needed here
import { Button } from "@maholan/ui";
export default function Page() {
return (
<main>
<Button color="primary" size="lg">
Get started
</Button>
<Button color="secondary" size="md">
Learn more
</Button>
<Button color="primary-destructive" size="md">
Delete
</Button>
</main>
);
}Components
Button
Accessible button built on React Aria. Renders as <button> by default,
switches to <a> (via React Aria Link) when href is provided.
import { Button } from "@maholan/ui";
// Standard button
<Button color="primary" size="md">Save changes</Button>
// Link button (renders as <a>)
<Button href="/dashboard" color="link-gray">Go to dashboard</Button>
// With icon slots — pass FC (receives className) or ReactNode
<Button iconLeading={PlusIcon} color="primary">Add item</Button>
<Button iconLeading={<PlusIcon className="size-4" />} color="primary">Add item</Button>
<Button iconTrailing={<ArrowRightIcon />} color="link-color">Continue</Button>
// Loading state
<Button isLoading color="primary">Saving…</Button>
// Disabled
<Button isDisabled color="primary">Unavailable</Button>
// Full width
<Button fullWidth color="primary">Submit form</Button>color prop: primary · secondary · tertiary · link-gray ·
link-color · primary-destructive · secondary-destructive ·
tertiary-destructive · link-destructive
Sizes: sm (36px, default) · md (40px) · lg (44px) · xl (48px) ·
2xl (60px)
ButtonUtility
Icon-only button for compact actions (e.g., toolbar buttons, close icons within larger components). No text content — only an icon.
import { ButtonUtility } from "@maholan/ui";
<ButtonUtility size="md" aria-label="Delete item">
<TrashIcon />
</ButtonUtility>;CloseButton
Specialised icon-only button for dismissing modals, toasts, and drawers. Pre-configured with the correct ARIA label and close icon.
import { CloseButton } from "@maholan/ui";
<CloseButton size="sm" onPress={handleClose} />;Variant Props
Every component re-exports its VariantProps type so you can type wrappers
without duplicating the union:
import { Button, type ButtonVariantProps } from "@maholan/ui";
interface MyButtonProps extends ButtonVariantProps {
label: string;
}
function MyButton({ label, color, size }: MyButtonProps) {
return (
<Button color={color} size={size}>
{label}
</Button>
);
}Styling Override
Pass className to any component to add or override classes. Uses cn()
(clsx + tailwind-merge) internally so there are no specificity conflicts:
<Button color="primary" className="w-full rounded-full">
Full-width pill button
</Button>React Aria Render Props
All interactive components expose React Aria's render props. Use them to apply your own conditional styles:
<Button
color="secondary"
className={(renderProps) =>
cn(
"base-class",
renderProps.isFocusVisible && "ring-4 ring-blue-500",
renderProps.isPressed && "opacity-80"
)
}
>
Custom states
</Button>Icon Slots
Components that can contain icons accept iconLeading and iconTrailing. Pass
either a React function component (receives className automatically) or any
ReactNode. Not locked to any specific icon library:
import { SearchIcon } from "lucide-react";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// FC — className is injected automatically for consistent sizing
<Button iconLeading={SearchIcon}>Search</Button>
// ReactNode — rendered as-is
<Button iconLeading={<MagnifyingGlassIcon className="size-4" />}>Search</Button>
<Button iconLeading={<span>🔍</span>}>Search</Button>
// Trailing icon
<Button iconTrailing={ArrowRightIcon} color="link-color">Continue</Button>Package Exports
| Import | Contents |
| ----------------------- | -------------------------------------------------------- |
| @maholan/ui | All components, types, cn() utility |
| @maholan/ui/stories | All Storybook stories (for master Storybook aggregation) |
| @maholan/ui/storybook | Storybook decorators, viewports, and theme config |
Component File Structure
Each component lives in src/components/<group>/<name>/:
src/components/base/buttons/button/
├── button.tsx # Component implementation ("use client")
├── button.variants.ts # CVA variant definitions
├── button.stories.tsx # Storybook stories (autodocs + per-variant)
├── button.test.tsx # Vitest + jest-axe tests
└── index.ts # Barrel exportDevelopment
# Build the package
pnpm build
# Watch mode
pnpm dev
# Run tests
pnpm test
# Type check
pnpm type-check
# Start Storybook
pnpm storybookRelated Packages
@maholan/tokens— Design tokens andmhl-tokens.css@maholan/theme—ThemeProvider,ThemeScript,useTheme@maholan/cli— CLI to copy component source into consumer projects@maholan/registry— Registry JSON builder
License
MIT
