Building a shadcn/ui Style Component Library for Korean Government Design System
Source: Dev.to
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 (
<button
className={cn(buttonVariants({ variant, size }), className)}
aria-busy={loading}
{...props}
>
{loading && /* loading indicator placeholder */}
{children}
</button>
);
}
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:
// Example usage
<div className="text-krds-primary-base">
KRDS‑compliant text
</div>
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 (
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="bg-white p-4 rounded">
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
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: