# How to Create Reusable Design System Primitives in Next.js: A Complete Guide

> Build reusable design system primitives in Next.js with a complete guide. Learn to create self-contained components, use Tailwind, and export typed APIs for consistent UI.

- Repository: [Ege Chelebi/blog](https://github.com/woosal1337/blog)
- Tags: how-to-guide
- Published: 2026-08-06

---

**Create a scalable design system in Next.js by building self-contained components under `components/ds/` that use a shared `cn` utility for Tailwind class merging and export clean, typed APIs for consistent UI across your application.**

The `woosal1337/blog` repository demonstrates an elegant approach to UI architecture by isolating reusable design system primitives in a dedicated directory. Each primitive is a tiny, single-purpose component that encapsulates styling, accessibility, and interaction concerns while avoiding external CSS dependencies. By following this pattern, you can extend your component library with new building blocks that automatically inherit consistent styling and behavior.

## The Foundation: Project Structure and Utilities

Every robust design system starts with standardized tooling and directory organization. The repository establishes clear conventions that keep primitives isolated and maintainable.

### The `cn` Utility for Deterministic Class Merging

At the heart of the system lies the `cn` helper defined in [`lib/utils.tsx`](https://github.com/woosal1337/blog/blob/main/lib/utils.tsx). This function combines `clsx` for conditional class handling with `tailwind-merge` to resolve conflicting Tailwind utility classes:

```typescript
// File: lib/utils.tsx
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

```

According to the `woosal1337/blog` source code, every primitive imports this utility to keep className composition clean and predictable. This prevents the common pitfalls of template literal concatenation, such as conflicting `padding` or `margin` declarations.

### Directory Conventions

All design system primitives live under `components/ds/`, creating a clear boundary between low-level UI building blocks and feature-specific components. The repository uses a TypeScript path alias defined in [`tsconfig.json`](https://github.com/woosal1337/blog/blob/main/tsconfig.json) (`paths: { "@/*": ["*"] }`) to enable clean imports:

```typescript
import { Tag } from "@/components/ds/tag";
import { GlassButtonSurface } from "@/components/ds/glass-button";

```

This organization ensures that visual styling remains declarative and co-located with component logic, avoiding scattered CSS files that drift out of sync.

## Anatomy of a Design System Primitive

Primitives in this architecture follow strict patterns: they accept an optional `className` prop for extensibility, use the `cn` utility for final class composition, and export a single named function with explicit TypeScript types.

### Simple Primitive Example: The `Tag` Component

The [`components/ds/tag.tsx`](https://github.com/woosal1337/blog/blob/main/components/ds/tag.tsx) file illustrates the baseline pattern for a reusable pill-shaped label:

```typescript
// File: components/ds/tag.tsx
import { cn } from "@/lib/utils";

export function Tag({ 
  children, 
  className 
}: { 
  children: React.ReactNode; 
  className?: string 
}) {
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-[999px] border border-white/15 bg-white/[0.06] px-2.5 py-[3px] font-ui text-[12.5px] text-ink-soft backdrop-blur-[4px] [box-shadow:inset_0_1px_0_rgb(255_255_255/0.12)]",
        className
      )}
    >
      {children}
    </span>
  );
}

```

This component encapsulates all visual concerns—rounded corners, glass-morphism effects, and typography—while allowing parent components to pass additional utility classes via the `className` prop. The `cn` function merges these classes intelligently, preserving the base styles while applying overrides last.

### Advanced Primitive Composition: Glass Effects

For complex visual effects, primitives can wrap external low-level components. The [`components/ds/glass-button.tsx`](https://github.com/woosal1337/blog/blob/main/components/ds/glass-button.tsx) implements a reusable `GlassButtonSurface` using the `Vaso` library to achieve consistent glass-morphism across buttons:

```typescript
// File: components/ds/glass-button.tsx
"use client";

import { cn } from "@/lib/utils";
import { Vaso } from "vaso";

const GLASS_BTN =
  "rounded-[999px] border border-line text-ink-soft transition-colors duration-200 ease-house hover:border-line-strong hover:text-ink";

export function GlassButtonSurface({
  size = 40,
  className,
  children,
}: {
  size?: number;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <Vaso
      width={size}
      height={size}
      radius={size / 2}
      blur={0.4}
      depth={2.5}
      dispersion={0.18}
      className={cn(GLASS_BTN, className)}
    >
      <span className="grid place-items-center" style={{ width: size, height: size }}>
        {children}
      </span>
    </Vaso>
  );
}

```

By centralizing the glass effect logic in `GlassButtonSurface`, other components like `BackButton` or `ViewAllButton` simply wrap their icons inside this primitive. The visual complexity lives in one file, while consumers receive a clean API surface.

## Step-by-Step Guide to Creating New Primitives

Following the established pattern in `woosal1337/blog`, you can create a new design system primitive through these systematic steps:

**1. Create the Component File**

Establish a new file under `components/ds/` with a descriptive, lowercase name (e.g., [`badge.tsx`](https://github.com/woosal1337/blog/blob/main/badge.tsx)). Keep the filename consistent with the exported function name for discoverability.

**2. Import Dependencies**

Pull in the `cn` utility and React types. Avoid importing Next.js-specific modules like `next/link` unless the primitive itself handles navigation:

```typescript
import { cn } from "@/lib/utils";

```

**3. Define Explicit Props**

Use minimal TypeScript interfaces. Most primitives need only `children` and an optional `className`:

```typescript
interface BadgeProps {
  children: React.ReactNode;
  className?: string;
}

```

**4. Implement the Component Function**

Write the JSX with Tailwind classes passed through `cn`. Place base styles first, followed by the `className` prop to enable overrides:

```typescript
export function Badge({ children, className }: BadgeProps) {
  return (
    <span className={cn(
      "inline-flex items-center rounded-full bg-primary-600 px-2 py-0.5 text-xs font-medium text-white",
      className
    )}>
      {children}
    </span>
  );
}

```

**5. Add Documentation**

Include a brief JSDoc comment explaining the component's purpose and any design system conventions:

```typescript
/**
 * Badge – reusable pill label for status indicators and categorization.
 */

```

**6. Export and Consume**

Export the component as a named function. Import using the path alias anywhere in your Next.js application:

```typescript
import { Badge } from "@/components/ds/badge";

// Usage
<Badge className="mt-2">Beta</Badge>

```

## Accessibility and Motion Preferences

Reusable primitives must encapsulate accessibility concerns to ensure consistency across the application. The [`components/ds/reveal.tsx`](https://github.com/woosal1337/blog/blob/main/components/ds/reveal.tsx) component demonstrates how to handle motion preferences internally:

```typescript
// File: components/ds/reveal.tsx
const shouldReduceMotion = 
  typeof window !== "undefined" && 
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

export function Reveal({ children, delay = 0 }) {
  if (shouldReduceMotion) {
    return <>{children}</>;
  }
  // Animation implementation...
}

```

By checking for `prefers-reduced-motion` within the primitive itself, every instance of the `Reveal` component automatically respects user accessibility settings. This prevents individual developers from accidentally introducing motion barriers when using the design system.

## Summary

- **`cn` Utility**: Located in [`lib/utils.tsx`](https://github.com/woosal1337/blog/blob/main/lib/utils.tsx), this helper combines `clsx` and `tailwind-merge` to handle complex Tailwind class merging without conflicts.
- **Directory Structure**: Isolate all primitives under `components/ds/` and import using the `@/` path alias for clean, maintainable dependencies.
- **Component Pattern**: Export named functions with explicit TypeScript props, accept an optional `className`, and use `cn` to merge base styles with overrides.
- **Composition**: Wrap external low-level components (like `Vaso` for glass effects) within primitives to share complex visual logic across multiple UI elements.
- **Accessibility**: Bake in motion preferences and focus handling at the primitive level so all consuming components inherit these standards automatically.

## Frequently Asked Questions

### What makes a component a "primitive" in a design system?

A primitive is the smallest reusable UI building block that encapsulates a single visual or interaction pattern. In the `woosal1337/blog` repository, primitives like `Tag` and `GlassButtonSurface` live under `components/ds/` and handle specific concerns—such as glass-morphism effects or pill-shaped labels—without business logic or layout assumptions. They export clean APIs that can be composed into larger feature components.

### Why use the `cn` utility instead of template literals for Tailwind classes?

Template literals can create conflicting class combinations that Tailwind cannot resolve properly, such as `p-4 p-2` resulting in unpredictable padding. The `cn` utility in [`lib/utils.tsx`](https://github.com/woosal1337/blog/blob/main/lib/utils.tsx) uses `tailwind-merge` to intelligently resolve these conflicts, keeping the last defined value while also supporting conditional classes via `clsx`. This ensures deterministic styling regardless of how many className overrides a parent component passes.

### How do I handle accessibility in design system primitives?

Encapsulate accessibility concerns within the primitive itself. As implemented in [`components/ds/reveal.tsx`](https://github.com/woosal1337/blog/blob/main/components/ds/reveal.tsx), check for `prefers-reduced-motion` media queries inside the component to disable animations for users who require reduced motion. Handle focus states, ARIA attributes, and keyboard navigation at this low level so that every instance of the primitive automatically meets accessibility standards.

### Can I use external libraries like Vaso within my primitives?

Yes, wrapping external libraries inside your primitives is a recommended pattern for maintaining consistency. The `GlassButtonSurface` component in [`components/ds/glass-button.tsx`](https://github.com/woosal1337/blog/blob/main/components/ds/glass-button.tsx) wraps the `Vaso` library to provide a standardized glass-morphism effect. By abstracting third-party dependencies behind your own API, you create a buffer that allows you to swap implementations or upgrade versions without refactoring every component that uses the effect.