klynn-ui
v0.1.0
Published
A copy-paste-friendly, Radix-powered React component library, distributed as an npm package.
Maintainers
Readme
kiln-ui
A copy-paste-friendly React component library, built the way shadcn/ui builds
its components — Radix UI primitives underneath, class-variance-authority
for variants, Tailwind for styling, a cn() helper for class merging — but
packaged as a normal installable npm dependency instead of a CLI-copied
source tree.
What's in here
| Component | Pattern it demonstrates |
|---|---|
| Button | CVA variants + asChild via Radix Slot |
| Badge | CVA variants, no Radix dependency |
| Input, Textarea, Label | Plain forwardRef wrapper / Radix primitive wrapper |
| Card family | Compound component (Header/Title/Description/Content/Footer) |
| Avatar family | Radix primitive wrapper with image+fallback |
| Separator | Minimal Radix wrapper |
| Tooltip family | Radix + Portal, requires TooltipProvider |
| Dialog family | Radix + Portal, overlay, focus trap, animated states |
| DropdownMenu family | Radix + Portal, items/checkboxes/radio/submenus |
| Tabs family | Radix, stateful active-tab styling via data-state |
| Checkbox, RadioGroup, Switch | Radix form controls with custom indicator markup |
| Select family | Radix Select, Portal, scroll buttons, popper positioning |
| Slider | Radix Slider, dynamic thumb count for range selection |
| Toggle, ToggleGroup | Shared CVA variants exposed via context |
| Alert family | CVA variant box, no Radix dependency |
| AlertDialog family | Radix + Portal, non-dismissable confirmation pattern |
| Popover, HoverCard | Radix + Portal, positioned floating content |
| Accordion | Radix + height-animated open/close via --radix-accordion-content-height |
| Collapsible | Minimal 3-line Radix re-export |
| Progress | Radix, transform-based indicator |
| Skeleton | Plain pulsing placeholder, no Radix dependency |
| Table family | Semantic HTML table, styling only |
| Sheet family | Dialog primitive + CVA side variants (top/bottom/left/right) |
| Toast, Toaster, useToast/toast() | Radix Toast + standalone reducer-based store, called imperatively |
| Form family | react-hook-form integration: FormField/FormItem/FormControl wire labels, descriptions, and errors together via ARIA automatically |
Every component is small, readable, and meant to be copied and modified — the
same philosophy as shadcn/ui. The difference is kiln-ui is also published so
you can just npm install it instead of running a CLI to vendor the source.
Not included (yet): Calendar/DatePicker, Command/Combobox, Carousel,
NavigationMenu, Menubar, ContextMenu, Breadcrumb, Pagination, InputOTP,
AspectRatio, and a TanStack-powered DataTable. These either pull in a heavier
third-party dependency (react-day-picker, cmdk, embla-carousel) or are
thin compositions of components already here — happy to add any of them on
request.
Project structure
kiln-ui/
src/
components/ — one file per component (or component family)
lib/utils.ts — cn() helper (clsx + tailwind-merge)
styles/globals.css — CSS variable design tokens (light + dark)
tailwind.preset.ts — maps the CSS variables onto Tailwind theme keys
index.ts — barrel export
tsup.config.ts — bundles ESM + CJS + .d.ts, copies globals.css
package.jsonUsing it in a consuming app
npm install kiln-ui
npm install -D tailwindcss-animate # peer of the preset's animations1. Extend your Tailwind config with the preset:
// tailwind.config.ts
import type { Config } from "tailwindcss";
import kilnPreset from "kiln-ui/tailwind.preset";
export default {
presets: [kilnPreset],
content: [
"./src/**/*.{ts,tsx}",
"./node_modules/kiln-ui/dist/**/*.{js,mjs}", // <- important, see note below
],
} satisfies Config;Why the
node_modulescontent path matters: Tailwind only generates CSS for class names it can find as literal strings in the files listed undercontent. Sincekiln-ui's components ship as compiled JS containing those class strings, you must point Tailwind atkiln-ui'sdistfolder or the button/dialog/etc. styles will silently not exist in your final CSS.
2. Import the base CSS once, at your app root:
// app/layout.tsx, src/main.tsx, etc.
import "kiln-ui/styles.css";3. Use components:
import { Button, Card, CardHeader, CardTitle, CardContent } from "kiln-ui";
export function Example() {
return (
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
</CardHeader>
<CardContent>
<Button variant="default">Get started</Button>
<Button variant="outline">Learn more</Button>
</CardContent>
</Card>
);
}Dialog, DropdownMenu, and Tabs are compound components — import every
piece you need:
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "kiln-ui";
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>Tooltip needs a single TooltipProvider near the root of your app (wrap
your whole app, not each tooltip):
import { TooltipProvider } from "kiln-ui";
<TooltipProvider>
<App />
</TooltipProvider>Toast works like shadcn's original Radix-based toast: mount <Toaster />
once near your app root, then call toast() imperatively from anywhere —
no provider wrapping required at the call site, the store is module-level.
// app root
import { Toaster } from "kiln-ui";
<App />
<Toaster />
// anywhere else
import { toast } from "kiln-ui";
toast({ title: "Saved", description: "Your changes were saved." });
toast({ variant: "destructive", title: "Something went wrong" });Form wraps react-hook-form — install it in
your app (it's an optional peer dependency of kiln-ui, not bundled, so
there's exactly one copy of it and useForm()'s context matches what Form
reads):
npm install react-hook-formimport { useForm } from "react-hook-form";
import {
Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage,
Input, Button,
} from "kiln-ui";
function ProfileForm() {
const form = useForm({ defaultValues: { email: "" } });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => console.log(values))}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormDescription>We'll never share it.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}FormMessage automatically displays validation errors from react-hook-form's
field state — pair FormField's name with a Zod/Yup schema via
@hookform/resolvers for full validation, or just use RHF's built-in
rules/required props.
Theming
All colors are CSS variables in HSL channel form (--primary: 240 5.9% 10%),
set in globals.css and consumed by the Tailwind preset as
hsl(var(--primary)). To re-theme:
- Override the variables in your own CSS after importing
kiln-ui/styles.css. - Or copy
src/styles/globals.cssinto your app and edit the values directly, then skip importing the package's copy. - Toggle dark mode by adding/removing the
.darkclass on<html>(the preset setsdarkMode: ["class"]).
Local development
npm install
npm run dev # tsup --watch
npm run typecheck
npm run build # outputs to dist/ (esm, cjs, .d.ts, styles.css)There's no demo app wired up yet. The fastest way to test changes against a
real consumer project locally is npm link:
# in kiln-ui/
npm run build
npm link
# in your test app
npm link kiln-ui(Or use npm/pnpm workspaces if you keep the test app in the same monorepo.)
Publishing to npm
- Update
"name"inpackage.json—kiln-uiis almost certainly taken; either rename the package or publish under a scope you own, e.g.@yourname/kiln-ui(set"publishConfig": { "access": "public" }, which is already in place, for scoped public packages). - Bump
"version"(semver). npm loginif you haven't already.npm publish—prepublishOnlyautomatically runstypecheckandbuildfirst, and thefilesfield ensures onlydist/ships (no source, no config files).
Adding a new component (following the same pattern)
- Create
src/components/my-component.tsx. - For a component with visual variants (size/color/intent), define a
cva(...)call like inbutton.tsxand wire it throughVariantProps. - For anything interactive/accessible (menus, dialogs, toggles, tooltips),
check if Radix has a primitive first (
@radix-ui/react-*) and wrap it — don't reimplement focus management, ARIA, or portals by hand. - Always
React.forwardRefso consumers can pass refs through, and always run incomingclassNamethroughcn(...)last so consumer overrides win. - Export the component (and its variant function, if any) from
src/index.ts. - Run
npm run typecheck && npm run buildto confirm it compiles cleanly.
License
MIT
