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

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. This function combines clsx for conditional class handling with tailwind-merge to resolve conflicting Tailwind utility classes:

// 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 (paths: { "@/*": ["*"] }) to enable clean imports:

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 file illustrates the baseline pattern for a reusable pill-shaped label:

// 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 implements a reusable GlassButtonSurface using the Vaso library to achieve consistent glass-morphism across buttons:

// 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). 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:

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

3. Define Explicit Props

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

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:

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:

/**
 * 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:

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 component demonstrates how to handle motion preferences internally:

// 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, 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 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, 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →