Building a shadcn/ui Style Component Library for Korean Government Design System
Source: Dev.to
shadcn/ui changed how we think about component libraries. Instead of installing a package, you copy the source code into your project. You own it. You customize it.
I built HANUI using the same approach—for the Korean Government Design System (KRDS).
Architecture
┌─────────────────────────────────────────────┐
│ CLI (npx hanui add button) │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Component Registry (components.json) │
│ - Component definitions │
│ - Dependencies │
│ - File paths │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Your Project │
│ components/ui/button.tsx ← copied here │
└─────────────────────────────────────────────┘
CLI
The CLI is the entry point. It handles:
- init – Sets up your project
- add – Copies components to your project
- diff – Shows what changed (coming soon)
npx hanui init
# Creates:
# - components.json (config)
# - Copies variables.css (design tokens)
# - Updates tailwind.config.ts (adds preset)
npx hanui add button modal input
# Copies:
# - components/ui/button.tsx
# - components/ui/modal.tsx
# - components/ui/input.tsx
# - Any dependencies (like cn utility)
Component Structure
Each component follows a consistent pattern:
// button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base styles
'inline-flex items-center justify-center rounded font-medium transition-colors',
{
variants: {
variant: {
primary: 'bg-krds-primary-base text-white hover:bg-krds-primary-60',
secondary: 'bg-krds-gray-10 text-krds-gray-90 hover:bg-krds-gray-20',
tertiary: 'bg-transparent text-krds-primary-base hover:bg-krds-primary-5',
danger: 'bg-krds-danger-base text-white hover:bg-krds-danger-60',
},
size: {
sm: 'h-8 px-3 text-krds-label-sm',
md: 'h-10 px-4 text-krds-label-md',
lg: 'h-12 px-6 text-krds-label-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
loading?: boolean;
}
export function Button({
className,
variant,
size,
loading,
children,
...props
}: ButtonProps) {
return (
{loading && }
{children}
);
}
Key patterns
cvafor type‑safe variantscnutility for merging class names- KRDS tokens in Tailwind classes (
krds-primary-base,krds-label-md) - Built‑in accessibility (
aria-busy)
Design Token System
KRDS defines specific color, typography, and spacing values. HANUI implements them as CSS variables plus a Tailwind preset.
CSS Variables (variables.css)
:root {
/* Colors */
--krds-color-light-primary-50: #256ef4;
--krds-color-light-primary-60: #0b50d0;
/* Semantic tokens */
--krds-primary-base: var(--krds-color-light-primary-50);
--krds-primary-text: var(--krds-color-light-primary-60);
/* Typography */
--krds-fs-body-md: 17px;
--krds-fs-heading-lg: 24px;
}
.dark {
/* Dark mode overrides */
--krds-primary-base: var(--krds-color-light-primary-50);
--krds-primary-text: var(--krds-color-light-primary-40);
}
Tailwind Preset (tailwind.preset.ts)
const hanuiPreset = {
theme: {
extend: {
colors: {
'krds-primary': {
DEFAULT: 'var(--krds-primary-base)',
base: 'var(--krds-primary-base)',
text: 'var(--krds-primary-text)',
50: 'var(--krds-color-light-primary-50)',
60: 'var(--krds-color-light-primary-60)',
},
},
fontSize: {
'krds-body-md': ['17px', { lineHeight: '150%' }],
'krds-heading-lg': [
'var(--krds-fs-heading-lg)',
{ lineHeight: '150%', fontWeight: '700' },
],
},
},
},
};
Now you can use Tailwind classes that map directly to KRDS values:
KRDS‑compliant text
Radix UI Integration
Complex components (Modal, Select, Tabs) are built on Radix UI primitives.
Why Radix?
- Accessibility – WAI‑ARIA compliant out of the box
- Unstyled – Allows us to apply KRDS styling
- Composable – Flexible compound component API
// modal.tsx
import * as Dialog from '@radix-ui/react-dialog';
export function Modal({ children, ...props }) {
return (
{children}
);
}
export const ModalTitle = Dialog.Title;
export const ModalDescription = Dialog.Description;
export const ModalClose = Dialog.Close;
Resulting features:
- Focus trap
- Escape‑key handling
- Screen‑reader announcements
- Scroll lock
All provided for free; we only add KRDS styling.
Why This Approach?
- Full Control – You own the code. Need to change how a button looks? Edit
button.tsxdirectly. - No Runtime Dependency – Components are copied, not imported from
node_modules. Your bundle includes only what you use. - Tailwind Native – Everything uses Tailwind, matching modern React projects.
- Type Safety – TypeScript +
cvagives autocomplete for variants and compile‑time checks. - Accessible by Default – Radix primitives + ARIA attributes provide WCAG compliance out of the box.
Trade‑offs
- No automatic updates – You must manually update components when upstream changes.
- Larger initial setup – More files are added to your project.
- Learning curve – Understanding the component structure and the CLI takes some time.
For KRDS projects where customization is inevitable, these trade‑offs are worthwhile.
Get Started
npx hanui init
npx hanui add button input modal select tabs
GitHub:
Documentation: