handy-shadcnui
v1.0.2
Published
This repo is a port of [handy-fluentui](https://github.com/kc2wong/handy-fluentui), swapping FluentUI v9 for shadcn/ui as the underlying primitives while keeping the same component API surface (under a `Hui*` prefix instead of `Fui*`).
Downloads
509
Readme
Handy shadcn/ui
This repo is a port of handy-fluentui, swapping FluentUI v9 for shadcn/ui as the underlying primitives while keeping the same component API surface (under a Hui* prefix instead of Fui*).
- Consistent label / hint / error / info layout via the
withInputFieldHOC - Automatic mobile adaptation (breakpoint-driven layouts, bottom-sheet drawers, stacking layouts)
- Imperative
useToast,useSpinner,useDialogAPIs - Light/dark/custom theming with a global toggle shortcut and cross-tab sync
A live demo of most of the components is available at kc2wong.github.io/handy-shadcnui.
Commands
yarn dev # Start Vite dev server
yarn build # Type-check (tsc -b) then production build
yarn build:lib # Build a publishable library bundle (dist/)
yarn typecheck # Type-check only, no emit
yarn lint # ESLint over the whole repo
yarn format # Prettier --write on **/*.{ts,tsx}
yarn shadcn:sync # Regenerate src/components/shadcn/ui/** via the shadcn CLIsrc/components/shadcn/ui/** is gitignored and CLI-managed — never hand-edit it; run shadcn:sync to (re)generate it, then restart the dev server.
Setup
1. Install
yarn add handy-shadcnui2. Peer dependencies
react, react-dom, and @base-ui/react are declared as peerDependencies — install them explicitly alongside handy-shadcnui:
yarn add react react-dom @base-ui/reactThey're peers rather than regular dependencies specifically so your app supplies a single shared copy of each — letting your package manager install its own nested copy risks two React (or two @base-ui/react) instances coexisting, which breaks context-based state (hooks throwing "context is missing" errors) since a provider from one copy isn't recognized by consumers using the other.
Everything else handy-shadcnui needs (lucide-react, date-fns, embla-carousel-react, react-day-picker, usehooks-ts, clsx, tailwind-merge, class-variance-authority) is a regular dependency and installs automatically with it — no extra step. You'll also need tailwindcss itself for the next step, if your app doesn't already have it.
3. Tailwind CSS v4
Every Hui* component is styled with Tailwind utility classes, not shipped CSS — the package contains no .css file. Your app needs its own Tailwind v4 setup that:
Defines the design tokens Hui components' classes resolve against (
--background,--foreground,--primary, the@theme inlinemapping, etc.). Copy the@theme inlineblock and the:root/.darkvariable definitions from this repo'ssrc/index.cssas a starting point — Hui components assume the same shadcn/ui token set (base-novastyle,neutralbase color).Scans
handy-shadcnuifor utility classes. Tailwind always excludesnode_modulesfrom its automatic class detection. Sincehandy-shadcnuiships compiled JS rather than source.tsx, any utility class referenced only inside a Hui component's compiled output — and never literally written in your own app's source — is silently skipped unless you add:@source "../node_modules/handy-shadcnui/dist/index.js";(path relative to wherever your CSS entrypoint lives). Skipping this is the single most common cause of a Hui prop that "does nothing": the class name lands in the DOM correctly, but no matching CSS rule exists to render it.
labelWidthonHuiRadioGroup(w-1/4,w-1/3,w-1/2) is a concrete example that depends on this.
4. Provider
Wrap your application in HandyShadcnUiProvider once at the root. All components and hooks must be descendants of this provider.
import { HandyShadcnUiProvider } from 'handy-shadcnui';
function App() {
return (
<HandyShadcnUiProvider
mobileBreakpoint={600}
theme={{ defaultTheme: 'light' }}
component={{
toast: { dismissTimeout: 3000 },
}}
>
{/* your app */}
</HandyShadcnUiProvider>
);
}Provider props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| mobileBreakpoint | number | 600 | Viewport width (px) at which mobile layout activates |
| component | Component | — | Spinner and toast configuration |
| theme | ThemeConfig | — | Theme configuration — see below |
| loggerConfig | { logMessage? } | console.log | Custom logger |
| children | ReactNode | — | Required |
theme
type ThemeConfig = {
/** Initial theme when nothing is stored yet. Defaults to the OS color-scheme preference. */
defaultTheme?: 'light' | 'dark' | 'custom';
/** localStorage key the selected theme is persisted under. Defaults to 'theme'. */
storageKey?: string;
/** Briefly disables CSS transitions while the theme class swaps, to avoid a flash. Defaults to true. */
disableTransitionOnChange?: boolean;
/** Disables the global 'd' keydown shortcut that toggles light/dark. Defaults to false. */
disableThemeToggleShortcut?: boolean;
};The active theme is applied by toggling a light / dark / custom class on <html>. A custom theme needs its own token overrides defined in your app's CSS (the same way .dark overrides are defined) — none are provided by default. There is no persistent "system" value: defaultTheme (when unset and nothing is in localStorage) resolves the OS color-scheme preference once at mount, rather than staying live-synced to it. Pressing d anywhere outside an editable element toggles light/dark (disable via disableThemeToggleShortcut); the active theme also syncs across browser tabs.
component config
type Component = {
spinner?: SpinnerContextConfig;
toast?: ToastContextConfig;
};Component-specific labels (HuiTable pagination text, HuiImageCarousel tooltips, HuiInputMultiLangText language names) are passed directly as props on each component — see each component's section below.
Hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useTheme() | { currentTheme, switchTheme } | Read and change the active theme |
| useIsMobile() | boolean | True when viewport ≤ mobileBreakpoint |
| useBreadcrumb() | { items, isCollapsed, toggleCollapsed, start, append, peek, popTill } | Read and update the trail rendered by HuiBreadcrumb |
| useToast() | { success, error, info, warning } | Show toast notifications |
| useSpinner() | { show, hide } | Show/hide the global overlay spinner |
| useDialog() | { openDialog } | Show an imperative confirmation dialog |
| useLogger() | (message, level?) => void | Log via the configured logger |
| useTimeZone() | { timeZone, setTimeZone, zonedDate2LocalDate } | Read, update, and decompose dates in the active time zone |
All hooks throw if called outside HandyShadcnUiProvider.
useTimeZone
The provider initialises timeZone from Intl.DateTimeFormat().resolvedOptions().timeZone (the browser's local time zone). useTimeZone lets you read or override it and decompose a Date object into its constituent parts within that zone.
const { timeZone, setTimeZone, zonedDate2LocalDate } = useTimeZone();
// Read the active time zone
console.log(timeZone); // e.g. 'Asia/Tokyo'
// Switch to a different time zone (validated; invalid values are ignored with a warning)
setTimeZone('America/New_York');
// Extract date parts in the active time zone
const parts = zonedDate2LocalDate(new Date());
// { year, month, day, hour, minute, second }
// Extract date parts in an explicit time zone (overrides the active one for this call)
const tokyoParts = zonedDate2LocalDate(new Date(), 'Asia/Tokyo');LocalDate
type LocalDate = {
year: number;
month: number; // 1–12
day: number; // 1–31
hour: number; // 0–23
minute: number; // 0–59
second: number; // 0–59
};| Return value | Type | Description |
|---|---|---|
| timeZone | string | Currently active IANA time zone identifier |
| setTimeZone | (tz: string) => void | Update the active time zone. Invalid identifiers are ignored and logged as a warning. |
| zonedDate2LocalDate | (date: Date, tz?: string) => LocalDate | Decompose a Date into year/month/day/hour/minute/second in the active (or an explicitly supplied) time zone. Falls back to local time and logs a warning if tz is invalid. |
Common field props (FieldLayoutProps)
Every form input component (HuiInputText, HuiInputDate, HuiRadioGroup, ...) inherits these props from the withInputField HOC:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| label | string \| null | — | Field label. null suppresses the label container entirely |
| required | boolean | false | Shows a red asterisk next to the label |
| hint | string | — | Supplemental info shown in a click-triggered tooltip (info icon appears) |
| errorMessage | string | — | Error text shown in red below the input |
| infoMessage | string | — | Grey helper text below the input (hidden when errorMessage is present) |
| noMessage | boolean | false | Suppresses the message area and its reserved space |
| additionalMessage | ReactNode | — | Extra content on the right of the message row |
| clearable | boolean | true | Shows an eraser icon that clears the value |
| layout | 'vertical' \| 'horizontal' | 'vertical' | Label position: above or to the left of the input |
| labelWidth | 'quarter' \| 'third' \| 'half' \| 'auto' | — | Fixed label width when layout='horizontal' |
Horizontal layout automatically collapses to vertical on mobile.
Most input components also accept an appearance?: 'outline' | 'underline' prop controlling the input box's visual style (a full box outline vs. just a bottom border), and a contentBefore prop for rendering an icon or other content left-aligned inside the input.
Components
HuiInputText
Text input with optional show/hide toggle for passwords.
<HuiInputText
label="Full Name"
value={name}
onChange={setName}
required
hint="Enter your legal name."
/>
<HuiInputText
label="Password"
value={password}
onChange={setPassword}
type="password"
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string \| null | Yes | Current value |
| onChange | (value: string \| null) => void | Yes | Change callback |
| type | 'text' \| 'email' \| 'password' | No | Defaults to 'text'. Password adds show/hide toggle; email blocks duplicate @. |
| contentBefore | ReactNode | No | Content rendered inside the input, left-aligned (e.g. an icon) |
| contentAfter | ReactNode | No | Content rendered inside the input, right-aligned (e.g. an icon button). Ignored when type is 'password'. |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
HuiInputTextArea
Multi-line text area with optional character counter.
<HuiInputTextArea
label="Biography"
value={bio}
onChange={setBio}
maxLength={300}
hint="Max 300 characters."
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string \| null | Yes | Current value |
| onChange | (value: string \| null) => void | Yes | Change callback |
| maxLength | number | No | Automatically appends a counter (n / max) unless additionalMessage is set |
| rows | number | No | Number of visible text lines. Defaults to 4. |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
HuiInputNumber
Number input with keystroke filtering and an optional spin-button mode.
{/* Plain number input */}
<HuiInputNumber
label="Age"
value={age}
onChange={setAge}
min={0}
max={120}
precision={0}
allowNegative={false}
/>
{/* Spin-button mode (set step) */}
<HuiInputNumber
label="Salary"
value={salary}
onChange={setSalary}
step={1000}
min={0}
formatter={(v) => `$${v.toLocaleString()}`}
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | number \| null | Yes | Current value |
| onChange | (value: number \| null) => void | Yes | Change callback |
| step | number | No | Enables spin-button mode (up/down arrows inside the input); direct typing is disabled. precision is fixed at 0 in this mode. |
| precision | number | No | Decimal places allowed. Defaults to 0. |
| min | number | No | Minimum value |
| max | number | No | Maximum value |
| allowNegative | boolean | No | When false, blocks the minus key. Defaults to true. |
| formatter | (value: number) => string | No | Formats the display value when the field is unfocused |
| contentBefore | ReactNode | No | Content rendered inside the input, left-aligned (e.g. an icon) |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
HuiInputDate
Date picker. Renders a popover calendar on desktop and a bottom-sheet drawer calendar on mobile.
<HuiInputDate
label="Date of Birth"
value={date}
onChange={setDate}
required
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | Date \| null | Yes | Selected date |
| onChange | (date: Date \| null) => void | Yes | Change callback |
| formatter | (date: Date \| null) => string | No | Custom date format function. Defaults to toLocaleDateString(). |
| placeholder | string | No | Placeholder text shown when empty |
| contentBefore | ReactNode | No | Content rendered inside the input, left-aligned (e.g. an icon) |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
| disabled | boolean | No | Disables interaction |
| readOnly | boolean | No | Suppresses the calendar popup/drawer; the field renders as plain read-only text |
HuiInputTime
Time picker with up/down arrows that adjust the active hh:mm[:ss] segment. Direct typing is disabled.
import { HuiInputTime, HuiTime } from 'handy-shadcnui';
const [shiftStart, setShiftStart] = useState<HuiTime | null>(null);
{/* 12-hour format with seconds */}
<HuiInputTime
label="Shift Start"
value={shiftStart}
onChange={setShiftStart}
in24HourFormat={false}
withSeconds
/>
{/* 24-hour format, cascade carry enabled */}
<HuiInputTime
label="Shift End"
value={shiftEnd}
onChange={setShiftEnd}
cascadeCarry
/>HuiTime type:
type HuiTime = {
hour: number; // 0–23
minute: number; // 0–59
second: number; // 0–59
};| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | HuiTime \| null | Yes | Current time value |
| onChange | (time: HuiTime \| null) => void | Yes | Change callback |
| in24HourFormat | boolean | No | When false, shows an AM/PM toggle. Defaults to true. |
| withSeconds | boolean | No | When true, shows the seconds segment. Defaults to false. |
| cascadeCarry | boolean | No | When true, incrementing past a segment boundary (e.g. 59m → 0m) also advances the next segment. Defaults to false. |
| contentBefore | ReactNode | No | Content rendered inside the input, left-aligned. Shares space with the AM/PM toggle. |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
| readOnly | boolean | No | Hides the up/down arrows and AM/PM toggle; the input becomes non-interactive |
HuiInputDropdown
Dropdown with single or multi-select. Renders a bottom-sheet drawer on mobile with the field label (or placeholder) as the drawer title.
const options = [
{ value: 'hk', text: 'Hong Kong', group: 'Asia' },
{ value: 'gb', text: 'United Kingdom', group: 'Europe' },
];
{/* Single select */}
<HuiInputDropdown
label="Country"
value={country}
onChange={(val) => setCountry(val as string | null)}
options={options}
/>
{/* Multi-select */}
<HuiInputDropdown
label="Tags"
value={tags}
onChange={(val) => setTags(val as string[])}
options={options}
multiselect
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string \| string[] \| null | Yes | Selected value(s) |
| onChange | (value: string \| string[] \| null) => void | Yes | Change callback |
| options | HuiInputDropdownOption[] | Yes | Option list |
| multiselect | boolean | No | Enable multi-select mode. Defaults to false. |
| placeholder | string | No | Placeholder text shown when empty |
| disabled | boolean | No | Disables interaction |
| readOnly | boolean | No | The popup/drawer no longer accepts a new selection |
| appearance | 'outline' \| 'underline' | No | Defaults to 'outline' |
HuiInputDropdownOption
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string | Yes | Option value |
| text | string | Yes | Display text |
| group | string | No | Group label |
| disabled | boolean | No | Disables this option |
| render | () => ReactNode | No | Custom option content renderer |
HuiRadioGroup / HuiRadio
Radio group with a shared label. HuiRadio items are independent components.
<HuiRadioGroup
label="Gender"
value={gender}
onChange={setGender}
layout="horizontal"
>
<HuiRadio label="Male" value="male" />
<HuiRadio label="Female" value="female" />
<HuiRadio label="Other" value="other" />
</HuiRadioGroup>HuiRadioGroup props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string | No | Controlled selected value |
| defaultValue | string | No | Uncontrolled initial value |
| name | string | No | Shared name for the underlying radio inputs on form submission |
| onChange | (value: string) => void | No | Change callback |
| disabled | boolean | No | Disables the entire group |
| required | boolean | No | Marks the group mandatory |
| readOnly | boolean | No | Silently ignores changes |
| layout | 'vertical' \| 'horizontal' | No | Radio button layout direction. horizontal-stacked is not supported. |
HuiRadio props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string | Yes | Value submitted when this option is selected |
| label | string | Yes | Label rendered next to the radio button |
| disabled | boolean | No | Disables interaction |
| id | string | No | Optional id override; auto-generated when omitted |
HuiCheckbox
Checkbox with optional read-only mode.
<HuiCheckbox
label="I agree to the terms"
checked={agreed}
onChange={setAgreed}
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| label | string | Yes | Text label rendered next to the checkbox |
| checked | boolean | No | Checked state. Defaults to false. |
| onChange | (checked: boolean) => void | Yes | Change callback — receives boolean directly |
| disabled | boolean | No | Disables interaction |
| readOnly | boolean | No | Visually interactive but ignores changes |
HuiSwitch
Toggle switch. onChange delivers a boolean directly.
<HuiSwitch
label="Receive notifications"
checked={notifications}
onChange={setNotifications}
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| label | string | Yes | Text displayed next to the switch |
| checked | boolean | No | Checked state. Defaults to false. |
| defaultChecked | boolean | No | Initial value for uncontrolled usage |
| onChange | (value: boolean) => void | Yes | Change callback — receives boolean directly |
| disabled | boolean | No | Disables interaction |
| readOnly | boolean | No | Silently ignores changes |
HuiInputMultiLangText
Text input for multi-language values. A translate icon opens a drawer with one field per configured language (up to 3).
<HuiInputMultiLangText
label="Job Title"
value={jobTitle}
onChange={setJobTitle}
/>value / onChange use MultiLangText:
type MultiLangText = {
valueInLangOne: string | null;
valueInLangTwo: string | null;
valueInLangThree: string | null;
};| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | MultiLangText \| null | Yes | Multi-language text value |
| onChange | (value: MultiLangText \| null) => void | Yes | Change callback |
| label | string | Yes | Field label — also used as the drawer title |
| langLabel | { languages: string[] } | No | Names of each language slot shown in the drawer (up to 3). When fewer than 2 are provided, the translate icon is hidden. |
| textComponent | ComponentType<HuiInputTextProps> | No | Overrides the inner text component. Defaults to HuiInputText. |
HuiInputGroup
Groups multiple inputs under one shared label with weighted flex distribution. Items stack vertically on mobile.
<HuiInputGroup
label="City / Zip"
items={[
{ element: <HuiInputText value={city} onChange={setCity} placeholder="City" />, weight: 2 },
{ element: <HuiInputText value={zip} onChange={setZip} placeholder="Zip" />, weight: 1 },
]}
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| label | string | Yes | Shared label for the group |
| items | { element: ReactElement; weight?: number }[] | Yes | Inputs with optional flex-grow weights (default 1) |
Each item's own label is hidden; use the group-level label instead.
HuiTable / HuiColumn
Data table driven by HuiColumn children. Supports sorting and pagination with horizontal scroll.
<HuiTable
data={records}
pagination={{
offset: 0,
pageSize: 10,
pageSizeOption: [5, 10, 20],
totalRecord: records.length,
position: 'bottom',
}}
width={{ minWidth: '560px' }}
>
<HuiColumn field="id" header="ID" style={{ width: '10%' }} />
<HuiColumn field="name" header="Name" sortable style={{ width: '40%' }} />
<HuiColumn
field="status"
header="Status"
builder={(value) => <Badge>{String(value)}</Badge>}
style={{ width: '20%' }}
/>
</HuiTable>HuiTable props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| data | T[] | Yes | Array of row objects |
| pagination | PaginationProps | No | Pagination configuration |
| onPageOrSort | (page?, sort?) => void | No | Called on page change or sort click. Omit to use local sort state (a warning is logged when sorting without this handler). |
| width | Pick<CSSProperties, 'width' \| 'minWidth' \| 'maxWidth'> | No | Minimum width of the scrollable table area; the table always fills the parent, and a horizontal scrollbar appears below this width |
| langLabel | HuiTableLabel | No | Pagination text overrides. pageRange and paginationBar.nextN/previousN support template tokens ({{from}}, {{to}}, {{total}}, {{n}}). |
HuiColumn props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| field | string | Yes | Dot-notation path into the row object (e.g. "address.city") |
| header | string | Yes | Column header text |
| sortable | boolean | No | Makes the column header clickable for sorting |
| align | 'left' \| 'center' \| 'right' | No | Cell text alignment |
| formatter | (value, row) => string | No | Format function for plain text cells |
| builder | (value, row) => ReactNode | No | Render function for rich content (mutually exclusive with formatter) |
| style | CSSProperties | No | Cell styles (use to set column width) |
| headerStyle | CSSProperties | No | Header-cell-specific styles |
| headerEllipsis | boolean | No | Truncate long header text with ellipsis |
PaginationProps
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| offset | number | Yes | Zero-based row offset of the current page |
| pageSize | number | Yes | Rows per page |
| totalRecord | number | Yes | Total number of records |
| pageSizeOption | number[] | Yes | Available page size choices |
| position | 'top' \| 'bottom' | No | Defaults to 'bottom' |
| fastForwardPage | number | No | Pages to jump on << / >>. Defaults to 5. |
HuiTabList / HuiTab
Tabbed panel with an inline content panel and an underline active-tab marker. On mobile the tab bar becomes horizontally scrollable and vertical layout is forced horizontal.
<HuiTabList<string>
selectedValue={tab}
onTabSelect={(data) => setTab(data.value)}
>
<HuiTab name="Personal" value="personal">
<PersonalForm />
</HuiTab>
<HuiTab name="Employment" value="employment">
<EmploymentForm />
</HuiTab>
</HuiTabList>| Prop (HuiTabList) | Type | Required | Description |
|---------------------|------|----------|-------------|
| selectedValue | T | No | Currently active tab value |
| onTabSelect | (data: { value: T }) => void | No | Selection change callback |
| vertical | boolean | No | Side-by-side layout (forced horizontal on mobile) |
| Prop (HuiTab) | Type | Required | Description |
|-----------------|------|----------|-------------|
| name | string | Yes | Tab button label |
| value | T | No | Tab identifier — defaults to name |
| children | ReactNode | No | Content panel |
| icon | ReactNode | No | Icon shown in the tab button |
| disabled | boolean | No | Disables this tab |
HuiImageCarousel
Circular image carousel with autoplay and navigation controls.
<HuiImageCarousel
images={[
'https://example.com/photo1.jpg',
'https://example.com/photo2.jpg',
]}
/>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| images | string[] | Yes | Image URLs |
| langLabel | { autoplay?: string; next?: string; previous?: string } | No | Tooltip label overrides for the navigation buttons |
HuiButtonPanel
Flex row of action buttons. Collapses to a full-width stacked column on mobile.
<HuiButtonPanel alignItems="right">
<HuiButton appearance="secondary" onClick={onCancel}>Cancel</HuiButton>
<HuiButton appearance="primary" onClick={onSave}>Save</HuiButton>
</HuiButtonPanel>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| alignItems | 'left' \| 'right' | No | Horizontal alignment. Defaults to 'right'. |
| children | ReactNode | Yes | Button elements |
HuiButton / HuiIconButton
Button with appearance/size/icon. HuiIconButton is a thin wrapper that always renders icon-only (no children) and defaults to a subtle appearance, for toolbar/close-button usage.
<HuiButton appearance="primary" onClick={onSave}>Save</HuiButton>
<HuiIconButton aria-label="Close" icon={<X className="h-4 w-4" />} onClick={onClose} />HuiButton props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| appearance | 'primary' \| 'outline' \| 'subtle' \| 'transparent' \| 'secondary' | No | Defaults to 'secondary' |
| size | 'small' \| 'medium' \| 'large' | No | Defaults to 'medium' |
| icon | ReactNode | No | Icon rendered alongside the label. An icon with no children renders a square icon-only button. |
| iconPosition | 'before' \| 'after' | No | Side of the label the icon is rendered on. Defaults to 'before'. |
| children | ReactNode | No | Button label |
HuiIconButton takes the same props minus children/iconPosition, with icon and aria-label required.
HuiToggle
Pressable toggle button.
<HuiToggle checked={bold} icon={<Bold className="h-4 w-4" />} onClick={() => setBold((v) => !v)} />| Prop | Type | Required | Description |
|------|------|----------|-------------|
| checked | boolean | No | Controlled pressed state |
| defaultChecked | boolean | No | Initial pressed state for uncontrolled usage. Defaults to false. |
| appearance | 'primary' \| 'outline' \| 'subtle' \| 'transparent' \| 'secondary' | No | Defaults to 'secondary' |
| size | 'small' \| 'medium' \| 'large' | No | Defaults to 'medium' |
| icon | ReactNode | No | Icon rendered alongside the label |
| iconPosition | 'before' \| 'after' | No | Defaults to 'before' |
HuiDivider
Thin horizontal rule used to separate page sections, with an optional centered label.
<HuiDivider>Section Title</HuiDivider>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| children | ReactNode | No | Optional label rendered centered on the divider line |
HuiText and typography variants
Typography element covering label/title/subtitle/body/caption styles. HuiLabel, HuiTitle1, HuiTitle2, HuiSubTitle1, HuiSubTitle2, HuiBody1, HuiBody2, HuiCaption1, HuiCaption2 are HuiText fixed to the matching type.
<HuiTitle1>Page heading</HuiTitle1>
<HuiText type="body2" text="Body copy" />| Prop | Type | Required | Description |
|------|------|----------|-------------|
| text | string | Yes | Text content to display |
| type | 'label' \| 'title1' \| 'title2' \| 'subTitle1' \| 'subTitle2' \| 'body1' \| 'body2' \| 'caption1' \| 'caption2' | No | Defaults to 'label'. Ignored on the fixed-type variants. |
| italic | boolean | No | Defaults to false |
| bold | boolean | No | Overrides the type's default weight. Defaults to false. |
| block | boolean | No | Renders as a block-level element instead of inline. Defaults to false. |
HuiTooltip
Wraps arbitrary content with a small message shown on hover (a Tooltip) or on click (a Popover, since the underlying tooltip primitive has no click-triggered mode). If the wrapped element is itself disabled (disabled or aria-disabled), no affordance is added and the message never shows.
<HuiTooltip text="Delete this record">
<HuiIconButton aria-label="Delete" icon={<Trash className="h-4 w-4" />} onClick={onDelete} />
</HuiTooltip>
<HuiTooltip showOn="click" text="Copied!" position="top">
<HuiButton onClick={copyToClipboard}>Copy</HuiButton>
</HuiTooltip>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| text | string | Yes | Text shown in the tooltip/popover |
| children | ReactElement | Yes | A single element to wrap |
| showOn | 'hover' \| 'click' | No | Defaults to 'hover' |
| dismissMs | number | No | When showOn is 'click', ms of inactivity before auto-dismiss. Defaults to 2000. |
| position | 'top' \| 'bottom' \| 'left' \| 'right' | No | Defaults to the underlying primitive's own placement |
HuiAccordion / HuiAccordionItem
Groups collapsible HuiAccordionItem panels, controlling which are expanded — single- or multi-expand.
<HuiAccordion value={openItem} onChange={setOpenItem} collapsible>
<HuiAccordionItem header="Details" value="details">
<DetailsForm />
</HuiAccordionItem>
<HuiAccordionItem header="Preferences" value="preferences">
<PreferencesForm />
</HuiAccordionItem>
</HuiAccordion>HuiAccordion props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value / onChange | string / (value: string) => void | Yes | Controlled expanded value (single mode, default) |
| value / onChange (when multiple) | string[] / (value: string[]) => void | Yes | Controlled expanded values (multi mode) |
| multiple | boolean | No | Allow more than one panel open at once. Defaults to false. |
| collapsible | boolean | No | When the open panel can be collapsed to leave none open. Not applicable when multiple. Defaults to false. |
| expandIcon | ReactNode | No | Overrides the default expand/collapse icon for every item |
| expandIconPosition | 'start' \| 'end' | No | Defaults to 'start' |
| withDivider | boolean | No | Renders a divider between panels. Defaults to true. |
HuiAccordionItem props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string | Yes | Unique identifier for this panel |
| header | ReactNode | Yes | Clickable header content |
| disabled | boolean | No | Disables toggling this item |
| expandIcon | ReactNode | No | Per-item icon override, falls back to the parent's expandIcon |
HuiCard / HuiCardHeader / HuiCardPreview / HuiCardFooter
Content-display container for a single topic's header, preview and footer.
<HuiCard>
<HuiCardHeader header="Quarterly Report" description="Q2 2026" />
<HuiCardPreview>
<img alt="" src="/report-preview.png" />
</HuiCardPreview>
<HuiCardFooter>
<HuiButton onClick={onView}>View</HuiButton>
</HuiCardFooter>
</HuiCard>| Prop (HuiCard) | Type | Required | Description |
|-------------------|------|----------|-------------|
| appearance | 'filled' \| 'filled-alternative' \| 'outline' \| 'subtle' | No | Defaults to 'filled' |
| orientation | 'horizontal' \| 'vertical' | No | Defaults to 'vertical' |
| size | 'small' \| 'medium' \| 'large' | No | Controls border radius and inner spacing. Defaults to 'medium'. |
| Prop (HuiCardHeader) | Type | Description |
|-------------------------|------|-------------|
| image | ReactNode | Image or avatar related to the card |
| header | ReactNode | Main header title |
| description | ReactNode | Short description related to the title |
| action | ReactNode | Content at the far end, e.g. an overflow menu button |
| Prop (HuiCardPreview) | Type | Description |
|---------------------------|------|-------------|
| logo | ReactNode | Small badge overlaid on the preview content |
| children | ReactNode | The preview image or content itself |
| Prop (HuiCardFooter) | Type | Description |
|--------------------------|------|-------------|
| action | ReactNode | Content at the far end, e.g. a single icon button |
| children | ReactNode | Main footer content, e.g. action buttons |
HuiDrawer / HuiDrawerHeader / HuiDrawerBody
Panel that hosts supplementary content or a management experience, dismissible ('overlay') or stacked with the page ('inline'). Forced to a bottom position on mobile regardless of position.
<HuiDrawer open={open} onOpenChange={setOpen} position="end">
<HuiDrawerHeader
title="Filters"
action={<HuiIconButton aria-label="Close" icon={<X className="h-4 w-4" />} onClick={() => setOpen(false)} />}
/>
<HuiDrawerBody>
<FiltersForm />
</HuiDrawerBody>
</HuiDrawer>| Prop | Type | Required | Description |
|------|------|----------|-------------|
| open | boolean | Yes | Controlled open state |
| onOpenChange | (open: boolean) => void | Yes | Fires on Escape, backdrop click, swipe-to-dismiss, etc. |
| position | 'start' \| 'end' \| 'bottom' | No | Defaults to 'start'. Ignored (always 'bottom') on mobile. |
| size | 'small' \| 'medium' \| 'large' \| 'full' | No | Defaults to 'small'. Ignored when effectively positioned at the bottom. |
| type | 'overlay' \| 'inline' | No | Defaults to 'overlay' |
| modalType | 'modal' \| 'non-modal' \| 'alert' | No | Overlay-only. Defaults to 'modal'. |
| separator | boolean | No | Inline-only. Whether the drawer has a separator line. Defaults to false. |
HuiDrawerHeader takes title/action; HuiDrawerBody renders scrollable main content.
HuiMenuBar
A horizontal bar of dropdown menus, e.g. a desktop-app-style File/Edit/View menu.
<HuiMenuBar>
<HuiMenuBarMenu label="File">
<HuiMenuBarItem icon={<FilePlus className="h-4 w-4" />} shortcut="Ctrl+N" onClick={onNew}>
New
</HuiMenuBarItem>
<HuiMenuBarSeparator />
<HuiMenuBarCheckboxItem checked={autosave} onCheckedChange={setAutosave}>
Autosave
</HuiMenuBarCheckboxItem>
<HuiMenuBarSub label="Export as">
<HuiMenuBarRadioGroup value={format} onValueChange={setFormat}>
<HuiMenuBarRadioItem value="pdf">PDF</HuiMenuBarRadioItem>
<HuiMenuBarRadioItem value="csv">CSV</HuiMenuBarRadioItem>
</HuiMenuBarRadioGroup>
</HuiMenuBarSub>
</HuiMenuBarMenu>
</HuiMenuBar>| Component | Key props |
|---|---|
| HuiMenuBar | children: one or more HuiMenuBarMenu |
| HuiMenuBarMenu | label, disabled |
| HuiMenuBarItem | icon, shortcut, disabled, onClick |
| HuiMenuBarCheckboxItem | checked, onCheckedChange, disabled, shortcut |
| HuiMenuBarRadioGroup | value, onValueChange — wraps HuiMenuBarRadioItem children |
| HuiMenuBarRadioItem | value, disabled |
| HuiMenuBarSeparator | className |
| HuiMenuBarLabel | Non-interactive group heading |
| HuiMenuBarSub | label, disabled — nested dropdown, triggered from within a parent menu |
HuiBreadcrumb
Renders the current trail from useBreadcrumb(). The last item is shown as the current page; earlier items without an action are non-interactive labels. Once there are at least 3 items, clicking the last item toggles a collapsed first > … > last view.
const breadcrumb = useBreadcrumb();
breadcrumb.start({ label: () => 'Home', action: () => navigate('/') });
breadcrumb.append({ label: () => 'Settings', action: () => navigate('/settings') });
<HuiBreadcrumb />HuiBreadcrumb takes no props — it's entirely driven by useBreadcrumb().
Toast notifications
Use the useToast() hook to show non-blocking feedback.
const toast = useToast();
toast.success('Saved!');
toast.error('Something went wrong.');
toast.info('Processing…');
toast.warning('Check your input.');Error toasts do not auto-dismiss. All other intents dismiss automatically after toast.dismissTimeout ms (configurable in the provider component.toast config). A dismiss button appears on error toasts after the timeout.
Spinner
Use useSpinner() to show a full-screen overlay spinner during async operations.
const spinner = useSpinner();
spinner.show();
await saveData();
spinner.hide();Confirmation dialog
Use useDialog() for imperative confirmation dialogs.
const dialog = useDialog();
dialog.openDialog({
title: 'Confirm Delete',
content: 'Are you sure?',
primaryButton: { label: 'Yes', action: handleDelete },
});