Contributing

Design principles

TypeScript UI is built on a set of core principles that guide every design decision. These principles ensure that components are accessible, performant, maintainable, and delightful to use.


Accessible by default

Components must be accessible without extra effort from the developer. No aria- attributes should be required for basic functionality.

Rationale: Accessibility is a feature, not an afterthought. Users with disabilities deserve the same experience as everyone else. Building accessibility into primitives ensures it's inherited by all compositions.

Examples in the codebase:

  • Radix UI primitives — All components like Accordion, Dialog, and Navigation Menu include full ARIA attributes and keyboard navigation
  • Labeled form inputs — Form components include associated labels, not just placeholders
  • aria-expanded on menus — The mobile menu uses aria-expanded="true|false" to announce state changes to screen readers
  • prefers-reduced-motion — Animated components like stats counters and logo marquees respect the prefers-reduced-motion media query, disabling animations for users who prefer it

Fully typed with TypeScript

Every component exports a Props interface. No any types. Type-safe by default.

Rationale: TypeScript catches mistakes at development time, not runtime. Fully typed props improve IDE autocomplete, enable refactoring, and make APIs self-documenting.

Examples in the codebase:

export interface CTASimpleProps {
  title: string;
  description: string;
  primaryButton: {
    text: string;
    href: string;
  };
  secondaryButton?: {
    text: string;
    href: string;
  };
  className?: string;
}

export function CTASimple({
  title,
  description,
  primaryButton,
  secondaryButton,
  className,
}: CTASimpleProps) {
  // ...
}

Users see full autocomplete and errors if they pass the wrong type.


Composition over configuration

Simple components that compose easily beat complex components with dozens of props.

Rationale: Complex props matrices explode in size and become hard to maintain. Composition—using multiple small components together—keeps each component focused and lets users build what they need.

Examples in the codebase:

  • Button variants — Instead of a single Button with 20 props, the button primitive exports different variants (default, outline, ghost, link, etc.) that can be combined
  • Feature sections — Separate components for FeatureCard, FeatureGrid, and FeatureIconList let users choose the layout that fits their content
  • Pricing sections — PricingCard, PricingSimple, and PricingComparison each serve a distinct use case

Dark mode is not optional

All components ship with full dark mode support. Dark mode must be complete, not an afterthought.

Rationale: Dark mode is expected by modern applications. Supporting it from day one ensures all components work in both light and dark contexts. This is a baseline requirement, not a bonus feature.

Examples in the codebase:

Every component includes both light and dark styles:

export function MyComponent({ title, description }) {
  return (
    <div>
      <h2 className="text-3xl font-bold text-slate-900 dark:text-white">
        {title}
      </h2>
      <p className="text-slate-600 dark:text-slate-400">
        {description}
      </p>
    </div>
  );
}

The dark: variants are defined in the same class list, not in separate CSS files. Dark mode is controlled via a dark class on the root element (typically managed by next-themes).


Responsive by default

All components work on mobile without extra configuration. Mobile-first design is the default.

Rationale: Most users browse on mobile. Responsive design should not be optional or require a separate mobile variant—it's the default. Use Tailwind's mobile-first breakpoints (sm, md, lg, xl) to enhance on larger screens.

Examples in the codebase:

  • Flexible layouts — CTASectionLayout uses flex flex-col items-center justify-center gap-4 sm:flex-row to stack buttons vertically on mobile and horizontally on larger screens
  • Responsive grids — FeatureGrid adapts from 1 column on mobile to 2-4 columns on desktop based on content
  • Responsive text — Headers use text-2xl sm:text-3xl md:text-4xl to scale across breakpoints

Zero runtime CSS

All styling is static Tailwind CSS. No emotion, styled-components, or runtime style injection.

Rationale: Static styles are faster (no runtime calculation), smaller (Tailwind's CSS is minimal and tree-shakeable), and more predictable. Runtime CSS adds complexity and performance overhead.

Examples in the codebase:

  • Tailwind utilities only — Every component uses className="..." with Tailwind utilities
  • No CSS modules — Styling is colocated via className, not in separate .module.css files
  • No dynamic CSS-in-JS — Variants are handled via conditional classes, not runtime style injection
// Good: Static Tailwind classes
<div
  className={clsx(
    'py-16 px-6 rounded-lg',
    variant === 'solid' && 'bg-sky-500 text-white',
    variant === 'outline' && 'border border-sky-500 text-sky-900 dark:text-sky-100',
  )}
>
  {children}
</div>

Sensible defaults with escape hatches

Components have clear defaults but always allow customization via className.

Rationale: Defaults make components easy to use out of the box. The className prop escape hatch lets power users override anything without forking the component.

Examples in the codebase:

export interface MyComponentProps
  extends React.HTMLAttributes<HTMLDivElement> {
  title: string;
  description: string;
  className?: string; // Escape hatch
}

export function MyComponent({
  title,
  description,
  className,
}: MyComponentProps) {
  return (
    <div className={clsx('py-16 text-center', className)}>
      <h2 className="text-3xl font-bold">
        {title}
      </h2>
      <p className="mt-4 text-slate-600">
        {description}
      </p>
    </div>
  );
}

Users can extend the component:

import { MyComponent } from '@/components/MyComponent';

export function CustomPage() {
  return (
    <MyComponent
      title="Custom styles"
      description="Add your own classes"
      className="!py-32 !text-left" // Override defaults
    />
  );
}

Use clsx/cn for dynamic classes

Conditional classes should be formatted clearly and combined using utility functions.

Rationale: String concatenation is error-prone. clsx and cn (alias for clsx with Tailwind merge) make conditional logic clear and handle edge cases like duplicate classes.

Examples in the codebase:

import clsx from 'clsx';

<button
  className={clsx(
    'px-4 py-2 rounded-lg transition-all',
    // Variants
    variant === 'primary' && 'bg-sky-500 text-white hover:bg-sky-600',
    variant === 'secondary' && 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-white',
    // Sizes
    size === 'sm' && 'text-sm',
    size === 'lg' && 'text-lg px-6 py-3',
    // Custom override
    className,
  )}
>
  {children}
</button>

Prefer 'use client' only when necessary

Server Components are the default. Use 'use client' only for interactivity, state, or hooks.

Rationale: Server Components are more secure (API keys stay private), lighter (less JavaScript shipped), and better for SEO. Client Components should be reserved for features that actually need them.

Examples in the codebase:

Server Component (no 'use client' directive):

// src/components/Header.tsx
export function StaticHeader() {
  return (
    <header className="bg-white dark:bg-slate-900">
      <h1 className="text-2xl font-bold">Welcome</h1>
    </header>
  );
}

Client Component (with 'use client' because of interactivity):

// src/components/Nav.tsx
'use client';

import { useState } from 'react';
import { Menu } from './Menu';

export function InteractiveNav() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <nav>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle Menu
      </button>
      {isOpen && <Menu />}
    </nav>
  );
}

Color palette: slate and sky

Use slate for neutral text/backgrounds and sky for accent colors. Never hardcode colors—use Tailwind color names so CSS variables can override them.

Rationale: A consistent palette prevents design fragmentation. Using Tailwind's naming keeps CSS variables in sync. Slate is neutral and professional; sky is friendly and accessible.

Examples in the codebase:

// Text colors
className="text-slate-900 dark:text-white"     // Foreground
className="text-slate-600 dark:text-slate-400" // Secondary text
className="text-slate-500 dark:text-slate-500" // Muted

// Accent colors
className="bg-sky-500 hover:bg-sky-600 dark:bg-sky-600"
className="border border-sky-500"
className="text-sky-700 dark:text-sky-300"

Never use hex colors directly. Always use Tailwind utilities.


Summary

These principles work together:

  • Accessibility ensures inclusive experiences
  • TypeScript catches errors early
  • Composition keeps code maintainable
  • Dark mode is standard
  • Responsive design works everywhere
  • Static Tailwind is performant
  • Sensible defaults make components easy to use
  • clsx and 'use client' sparingly keep code clean
  • Consistent colors and Tailwind utilities enable theming

Design in practice

Every component in TypeScript UI follows these principles. When you're unsure about a design decision, refer back to this guide.

Previous
Architecture guide