@akms/ui-kit
v0.3.0
Published
Headless-ish menu/icon/field UI kit whose color & radius tokens chain off HeroUI 3's theme variables, so dark mode and custom themes carry across the kit and HeroUI components without extra wiring.
Downloads
862
Maintainers
Readme
@akms/ui-kit
Tailwind-based menu / icon component kit. Built for HeroUI 3 — the kit's color and radius tokens chain off HeroUI's bare theme variables, so dark mode and custom themes carry across the menu and HeroUI components without extra wiring.
Install
npm install @akms/ui-kitPeer dependencies: react@^19, react-dom@^19, clsx, tailwind-merge, @iconify/react, @heroui/react@^3, @heroui/styles@^3, @akms/react-lib
Setup
1. Import preset.css into your Tailwind entry CSS (Tailwind v4)
Add it to the CSS file that has @import "tailwindcss" (your Tailwind entry):
@import "tailwindcss";
@import "@akms/ui-kit/preset.css";preset.css does two jobs in one import:
- defines the
--uikit-*theme tokens (:root), and - declares
@source "./lib/**/*.{js,cjs,mjs}"so Tailwind scans the kit's build output and generates the utility classes the components rely on.
⚠️ Import it through CSS
@importin the Tailwind entry — not a JS/TSimport "@akms/ui-kit/preset.css". A JS import pulls in the tokens, but Tailwind never processes the bundled@source, so the component utility classes are never generated and everything renders unstyled. You do not need a separate@source ".../@akms/ui-kit/lib/**"line —preset.cssalready carries it (with a./lib/**path that resolves relative to the package, so it's location-independent).
2. Theming
The kit's tokens chain off HeroUI 3's bare theme variables (--foreground / --accent / --border / --overlay / --radius), so theme switching is HeroUI's job: toggle a class like .dark on <html> (or define your own [data-theme="..."] block) and the kit follows automatically along with HeroUI components. No provider needed.
Usage
MenuList
Scrollable side-menu container. Accepts only MenuButton, MenuGroup, MenuPopover, MenuSelector, and MenuLabel as children.
import { MenuList, MenuButton } from "@akms/ui-kit";
<MenuList>
<MenuButton label="Home" startItem={{ icon: "tabler:home" }} />
</MenuList>By default MenuList fills its parent's available height (flex-1 h-full), so a sidebar menu stretches to the bottom. The base classes are merged with your className via tailwind-merge, so you can override the fill — e.g. for a short back-link row that should size to its content instead of growing:
<MenuList className="flex-none h-auto min-h-0">
<MenuButton label="← Back" startItem={{ icon: "tabler:arrow-left" }} onClick={goBack} />
</MenuList>⚠️ For SSR hydration issues, wrap the tree on the consumer side (e.g.
ClientOnly).
MenuButton
A clickable menu row.
<MenuButton
label="Dashboard"
startItem={{ icon: "tabler:layout-dashboard" }}
isSelected
onClick={() => navigate("/dashboard")}
/>| prop | type | description |
| --- | --- | --- |
| label | string \| ReactNode | Center label of the row |
| startItem | MenuSlot | Left slot (typically an icon) |
| endItem | MenuSlot | Right slot (typically action buttons) |
| isSelected | boolean | Selected state (primary color emphasis) |
| isDisabled | boolean | Disabled state (overlay + click blocked) |
| onClick | (e) => void | Click handler for the label area |
Putting action buttons in a slot
You can place interactive controls directly inside endItem.node. The inner label button and the slot containers live in separate grid columns, so there is no overlap — you do not need e.stopPropagation(). The row's hover / active background is also suppressed while a slot button is hovered or pressed, so the row doesn't visually compete with the action.
MenuIcon itself becomes a button when you pass onClick — that's usually the cleanest way to drop in icon-only actions:
<MenuButton
label="Document"
startItem={{ icon: "tabler:file-text" }}
endItem={{
node: <>
<MenuIcon icon="tabler:edit" title="편집" onClick={() => openEdit()} />
<MenuIcon icon="tabler:trash" title="삭제" onClick={() => openDelete()} />
</>,
}}
onClick={() => navigate("/docs")}
/>startItem hover toggle
Only startItem supports a hoverIcon pair. The icon swaps on row hover.
<MenuButton
label="Download"
startItem={{ icon: "tabler:download", hoverIcon: "tabler:download-off" }}
/>MenuGroup
Expand/collapse group. startItem is auto-filled with a chevron icon, but an explicit prop overrides it.
<MenuGroup label="Settings" startItem={{ icon: "tabler:settings" }}>
<MenuButton label="Profile" />
<MenuButton label="Security" />
</MenuGroup>MenuPopover
Same trigger row as MenuGroup, but clicking opens the items in a HeroUI popover flyout instead of expanding inline — built for sections with too many items to expand comfortably. The list scrolls inside the popover, so the item count never stretches the sidebar.
<MenuPopover label="All items" startItem={{ icon: "tabler:list" }}>
<MenuButton label="Item 1" onClick={() => navigate("/1")} />
<MenuButton label="Item 2" onClick={() => navigate("/2")} />
{/* ...dozens more — they scroll inside the popover */}
</MenuPopover>Add a search input at the top with searchable. The query matches item labels case-insensitively as a substring, and the popover height animates as results change (no jumpy resize between 0 and many results):
<MenuPopover label="Find item" startItem={{ icon: "tabler:list-search" }} searchable searchPlaceholder="이름으로 검색…">
{items.map((item) => (
<MenuButton key={item.id} label={item.name} onClick={() => navigate(`/${item.id}`)} />
))}
</MenuPopover>| prop | type | default | description |
| --- | --- | --- | --- |
| label | string \| ReactNode | — | Trigger row label |
| startItem | MenuSlot | — | Left slot (typically an icon) |
| endItem | MenuSlot | direction caret | Right slot; defaults to a caret pointing the popover direction |
| placement | HeroUI placement | "right top" | Which side the popover opens on (HeroUI 3 space-separated placement) |
| closeOnSelect | boolean | true | Close the popover when an item inside is clicked. Set false when nesting expand/collapse groups inside |
| searchable | boolean | false | Show a search input at the top and filter children by label substring (string labels only; non-string labels are hidden during search) |
| searchPlaceholder | string | "Search…" | Placeholder text for the search input when searchable is on |
MenuSelector
Radio-style group that manages single selection internally.
<MenuSelector
items={[
{ key: "ko", label: "한국어", startItem: { icon: "tabler:point" } },
{ key: "en", label: "English", startItem: { icon: "tabler:point" } },
]}
onSelect={(key) => i18n.changeLanguage(key)}
/>MenuLabel
Non-interactive section caption — also doubles as a static display row. Ships with built-in caption styling (font-semibold + top divider) so it visually separates sections without extra props.
<MenuLabel label="Language" />
{/* As a static display row — override the divider/bold via className if needed */}
<MenuLabel label="Version 1.2.3" startItem={{ icon: "tabler:info-circle" }} className="border-t-0 mt-0 pt-0 font-normal" />MenuIcon
@iconify/react wrapper that picks up the ui-kit color tokens. By default it renders as a non-interactive <div> with pointer-events: none, sized to follow the kit's text scale. Passing onClick turns it into a <button> with a 28×28 hit-area, kit-token hover/active feedback, and the same press animation as MenuButton — handy for slot action icons.
import { MenuIcon } from "@akms/ui-kit";
{/* Decorative icon */}
<MenuIcon icon="tabler:home" />
{/* Icon button — becomes <button> when onClick is provided */}
<MenuIcon icon="tabler:edit" title="편집" onClick={() => openEdit()} />| prop | type | default | description |
| --- | --- | --- | --- |
| icon | string | — | Iconify icon name ("tabler:home") or a single emoji character |
| size | number \| string | "1.125rem" | Icon visual size — font-size for emoji, fontSize prop on <IconifyIcon> |
| onClick | (e) => void | — | When set, renders as <button> with hit-area and press feedback |
| title | string | — | Forwarded to the rendered <button> (use for accessible tooltip) |
| isDisabled | boolean | false | Applies to button mode only — disables click and dims the icon |
Field
Form-row layout for settings / registration forms (Google-Play-Console style): a left label column and a right column holding an optional description (above the control), the control (children), and optional help text (below). Two columns on md+, stacked on mobile.
Text follows a three-tier emphasis so the row reads top-down: label at full strength (--uikit-foreground), description one step lighter (--uikit-foreground-secondary), and help faintest (--uikit-foreground-muted).
import { Field } from "@akms/ui-kit";
<Field
label="이름"
isRequired
description="스토어에 표시되는 이름입니다." // 컨트롤 위
help="2~50자, 영문/숫자" // 컨트롤 아래
>
<input className="..." />
</Field>| prop | type | description |
| --- | --- | --- |
| label | ReactNode | Left-column label |
| isRequired | boolean | Appends a * (danger color) after the label |
| description | ReactNode | Secondary text above the control |
| help | ReactNode | Secondary text below the control |
| children | ReactNode | The control(s) in the right column |
Keep label plain — express "required" with isRequired, not by embedding markup in the label.
Theming
The kit's color and radius tokens chain off HeroUI 3's unprefixed bare tokens (--foreground / --accent / --overlay / --border / --radius, in oklch), so the kit tracks HeroUI's dark-mode toggle and custom theme automatically — toggle .dark on <html> (or define [data-theme="..."]) and both the kit and HeroUI components paint together. When HeroUI isn't loaded, HeroUI's light-theme defaults below apply as fallbacks.
Override any of these in your own CSS to retune the kit without touching HeroUI itself (cascading through standard CSS works fine — a :root override, a parent-class override, or an inline style on any ancestor).
| variable | follows / fallback | purpose |
| --- | --- | --- |
| --uikit-foreground | --foreground / oklch(0.2103 0.0059 285.89) | Base text |
| --uikit-foreground-secondary | foreground 70% | Secondary text — Field description (above the control) |
| --uikit-foreground-muted | foreground 55% | Faint text — Field help (below the control) |
| --uikit-danger | --danger / oklch(0.637 0.237 25.331) | Danger / required-mark color (Field *) |
| --uikit-primary | --accent / oklch(0.6204 0.195 253.83) | Selected / accent color |
| --uikit-border | --border / oklch(90% 0.004 286.32) | Border |
| --uikit-surface | --overlay / oklch(100% 0 0) | Floating surface (popover) background |
| --uikit-radius | --radius × 3 / 1.5rem | Container corner radius (matches HeroUI's --radius-3xl tier — Popover / Button) |
| --uikit-radius-row | --radius × 2 / 1rem | Menu-row corner radius (matches HeroUI's --radius-2xl tier — menu-item) |
| --uikit-indent-step | 0.75rem | MenuGroup indent |
| --uikit-state-hover | foreground 5% | Hover background |
| --uikit-state-active | foreground 10% | Active background |
| --uikit-primary-bg | primary 18% | Selected background |
| --uikit-disabled-overlay | background 50% | Disabled overlay |
Full definitions are in preset.css.
Development
npm install
npm run build # build lib/
npm run watch # build on change
npm run playground # run the RR7 SSR playground demoplayground/ imports the library's src/ directly via alias, so HMR + sourcemap debugging works out of the box.
Structure
src/ # library (published)
playground/ # React Router 7 SSR demo
lib/ # build output (published)License
Proprietary — Copyright © 2026 Alkemic Studio. All rights reserved. See LICENSE.md.
