Chapter 2
Project setup
This chapter gets you from an empty directory to a working development environment with Vite, React 19, TypeScript, and Tailwind CSS 4. No components yet, just the foundation.
Before any token or component exists, the project needs a foundation that later chapters can build on without revisiting. That’s what this chapter is: install dependencies, configure the toolchain, lay out the directory structure, and add the single utility — cn() — that every component uses to join class names. The order matters. Each step assumes the previous one, so work through them top to bottom.
Initialize the project
mkdir rudiment-ui && cd rudiment-ui
npm init -y
Install the core dependencies:
npm install react react-dom
npm install -D typescript @types/react @types/react-dom
npm install -D vite@^7.3.1 @vitejs/plugin-react@^5.2.0
npm install -D @tailwindcss/vite@^4.2.1 tailwindcss@^4.2.1
Configure TypeScript
Create tsconfig.json at the project root:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
strict: true catches type errors early. jsx: "react-jsx" enables the automatic JSX runtime, so you don’t need to import React in every file. declaration: true generates .d.ts files that consumers need for TypeScript support.
Configure Vite
Create vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'node:url'
// Chapter 3 adds `"type": "module"` to package.json, which makes Vite load this
// file as an ES module. `__dirname` does not exist there, so derive it.
const rootDir = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(rootDir, 'src'),
},
},
})
For now, this configuration covers the dev server for Storybook development. Chapter 11 adds the test configuration to this same file, and Chapter 12 adds the library build for npm distribution. All three live in vite.config.ts; there is no separate Vitest config.
Set up the directory structure
mkdir -p src/components src/layouts src/typography src/hooks src/utils
mkdir -p src/docs src/stories
mkdir -p tokens/build
mkdir -p .storybook
There is no src/styles directory. Each component owns a stylesheet that sits next to it, imported by the component module itself, so src/components/Button/ will hold Button.tsx and button.css together. Chapter 4 explains why that colocation matters for the published build.
Now create the two CSS entry points. They are not interchangeable, and keeping them separate is what stops the library from restyling the applications that install it.
src/styles.css is what the published package ships:
/* Library CSS entry. Deliberately does NOT import Tailwind. */
Leave it as a comment for now; chapter 3 adds the token import.
src/app.css is the development and documentation entry point, loaded by Storybook:
@import 'tailwindcss';
That single line is all Tailwind CSS 4 needs to start. Tailwind is a development dependency here in every sense: it styles Storybook pages and example compositions, and it never reaches the package. Chapter 3 adds the token imports to both files.
Create the utility function
Every component needs a way to merge CSS classes. Create src/utils/cn.ts:
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Install the dependencies:
npm install clsx tailwind-merge
clsx handles conditional class joining. tailwind-merge resolves conflicts between Tailwind utilities: if a className contains both mt-2 and mt-4, only mt-4 survives. It does not let consumers override component styles with utilities. The library’s CSS is unlayered, and unlayered rules beat Tailwind’s @layer utilities whatever their specificity, so a consumer’s bg-red-500 loses to .rudi-button--primary. To restyle a component, override its --rudi-component-* tokens instead.
Verify the setup
Create src/vite-env.d.ts so TypeScript knows what a stylesheet import is:
/// <reference types="vite/client" />
Without it the CSS import below is an unresolved module, and tsc fails with
TS2307. Vite ships the declaration; the reference pulls it in. Every component
from chapter 4 onward imports a colocated stylesheet, so this file earns its
keep immediately.
Then create a minimal src/index.ts:
import './styles.css'
export { cn } from './utils/cn'
That first line is a side-effect import. It pulls the library’s stylesheet into the build, and every component stylesheet reaches the bundle the same way, through the component that owns it. Vite then emits all of that CSS as one dist/index.css, which consumers load once with import 'rudiment-ui/styles' (chapter 12).
Run the TypeScript compiler to verify everything is wired up:
npx tsc --noEmit
If this passes with no errors, your foundation is solid.
What you have now
- A Vite project with React 19 and TypeScript in strict mode
- Tailwind CSS 4 integrated via the Vite plugin, scoped to development
- A directory structure that separates components, layouts, typography, hooks, utils, tokens, and docs
- Two CSS entry points:
src/styles.cssfor the package,src/app.cssfor Storybook - A
cn()utility for class merging
No components, no tokens, no Storybook. That’s intentional. The next chapter builds the token system, and every component you write after that references tokens from day one. If you build components first and add tokens later, you end up retrofitting: slower work and more inconsistencies.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.