Skip to main content

From Wrapper Hell to a Single Source of Truth: Consolidating a Design System

Apr 4, 20263 min readReact, Design Systems, TypeScript, Tailwind CSS, Monorepo

The Problem

The portfolio site had grown organically. A shared-ui library existed with generic components (Button, Alert, Spinner), but the app had its own kit/ directory full of wrappers that re-exported shared-ui components with "app-specific" additions. The result was a layered mess:

  • Typography wrappers: kit/Heading.tsx imported shared-ui/Heading, added 7 app variants (hero, subtitle, mdxH1mdxH4), and duplicated the entire rendering logic — polymorphic tag selection, cn() class merging, variant-to-style mapping. kit/Text.tsx did the same for a single variant (subtitle).
  • Layout wrappers: kit/PageLayout.tsx wrapped shared-ui/PageContainer to add as='main' and spacing. kit/Section.tsx wrapped shared-ui/Section to flip two default props.
  • Component wrappers: kit/Spinner.tsx, kit/ErrorAlert.tsx, kit/SectionLabel.tsx, and others re-exported shared-ui equivalents with minor prop adaptations.

Every wrapper created a maintenance burden: parallel variant sets to keep in sync, duplicated type definitions, and confusion about where to import from. Consumers had to know which layer owned which variant. Pages mixed @/components/kit and @danieljoffe.com/shared-ui imports inconsistently.

The Principles

Before writing any code, I established three rules:

  1. shared-ui is the single source of truth for all reusable UI primitives — typography, layout, and generic components. It depends only on React and Tailwind CSS. No Next.js APIs.
  2. No wrappers that only add variants. If a component needs more variants, add them to shared-ui directly. Kit exists only for components that compose shared-ui with Next.js-specific APIs (Link, useRouter, next/image).
  3. Defaults should match the dominant usage pattern. If 15 out of 15 pages use the same spacing, bake it in as the default — don't make every consumer repeat the same props.

The Typography System

The first target was the Heading/Text split. The kit wrappers maintained a sharedVariants set to decide whether to delegate to shared-ui or render locally — a runtime check for a compile-time concern.

The fix was simple: move every variant into shared-ui.

Heading: 10 Variants

const variantStyles: Record<HeadingVariant, string> = {
  hero: 'text-4xl sm:text-5xl font-bold tracking-tight leading-[1.1]',
  detail: 'text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight',
  subtitle: 'text-2xl font-bold tracking-tight',
  section: 'text-2xl sm:text-3xl font-bold tracking-tight',
  cardTitle: 'text-sm font-semibold',
  component: 'text-lg font-semibold',
  mdxH1: 'text-2xl font-bold tracking-tight mb-6',
  mdxH2: 'text-lg font-semibold mt-12 mb-4 scroll-mt-20',
  mdxH3: 'text-sm font-semibold mt-8 mb-3 scroll-mt-20',
  mdxH4: 'text-xs font-medium mt-6 mb-2 uppercase tracking-wider',
};

Each variant maps to a sensible default HTML element (heroh1, cardTitleh3), overridable via the as prop. The consumer picks the visual intent; the component handles the semantics.

Text: 10 Variants

const variantStyles: Record<TextVariant, string> = {
  body: 'text-sm text-text-secondary leading-relaxed',
  bodyLg: 'text-base text-text-secondary leading-relaxed',
  subtitle: 'text-lg text-text-secondary leading-relaxed',
  cardDescription: 'text-sm text-text-secondary leading-relaxed',
  detail: 'text-xs text-text-secondary',
  label: 'text-xs font-semibold uppercase tracking-wider text-text-tertiary',
  meta: 'text-xs text-text-tertiary',
  caption: 'text-sm text-text-tertiary',
  helper: 'text-sm text-text-tertiary',
  error: 'text-sm text-error',
};

With the wrappers gone, every consumer imports directly from shared-ui:

import { Heading } from '@danieljoffe.com/shared-ui/Heading';
import { Text } from '@danieljoffe.com/shared-ui/Text';

No kit layer. No delegation. No variant sets to keep in sync.

Layout Primitives With Defaults

The second insight came from looking at the 15 pages that all repeated className='py-16 lg:py-24 space-y-24' on their PageLayout. If every consumer passes the same props, those aren't options — they're the default.

PageLayout

export function PageLayout({ children, wide = false, className, ...rest }) {
  return (
    <PageContainer
      as='main'
      id='main-content'
      size={wide ? 'md' : 'sm'}
      className={cn('py-16 lg:py-24 space-y-24', className)}
      {...rest}
    >
      {children}
    </PageContainer>
  );
}

Now a page is just:

<PageLayout>
  <Section>...</Section>
  <Section>...</Section>
</PageLayout>

No props needed for the common case. wide toggles to a wider container for data-heavy pages. Custom className merges with the defaults via cn().

Section

The shared-ui Section had defaults that didn't match reality: center=true and overflow='hidden'. Every page overrode both. I flipped the defaults and added the base horizontal padding that every section needed:

export function Section({
  children,
  padding = 'none',
  center = false,
  overflow = 'visible',
  className,
  ...rest
}) {
  return (
    <section
      className={cn('relative px-6 lg:px-0', /* variant classes */, className)}
      {...rest}
    >
      {children}
    </section>
  );
}

Component Promotion

With the typography and layout systems consolidated, the remaining kit components fell into two categories:

Moved to shared-ui (no Next.js deps)

  • Kbd — keyboard shortcut badge
  • GridBg — decorative CSS grid background
  • StructuredData — JSON-LD script injector
  • SectionLabel — icon + label + decorative rule
  • CTACard — card with heading, description, and action slot

Each was a pure React + Tailwind component that had been in kit only by convention. Moving them to shared-ui made them available to any app in the monorepo.

Kept in kit (Next.js dependent)

  • PostCard — uses next/link for client-side navigation
  • CoverImage — uses next/image for optimization
  • CompanyLogo — uses next/image

These genuinely compose Next.js APIs with shared-ui primitives. That's what kit is for.

Deleted entirely

  • kit/Heading.tsx, kit/Text.tsx — replaced by shared-ui variants
  • kit/PageLayout.tsx, kit/Section.tsx — replaced by shared-ui with better defaults
  • kit/ErrorAlert.tsx — replaced by <Alert variant='error'> + inline retry button
  • kit/Spinner.tsx — direct shared-ui import (labelaria-label)
  • ClientBadge, ClientSkeleton — React 19 ref prop pattern eliminated the need for client wrappers around server-compatible components

Enforcement

A design system without enforcement is just a suggestion. I added a custom ESLint rule that bans raw <h1> through <h6> elements in JSX, directing developers to use the Heading component instead. The rule catches violations at lint time, not in code review.

Results

  • 176 files changed, -4,282 / +2,273 lines — a net reduction of ~2,000 lines
  • 31 consumer files updated to import directly from shared-ui
  • 11 kit wrapper files deleted
  • Zero new dependencies — shared-ui still only requires React + Tailwind
  • All 1,266 unit tests pass. All 695 E2E tests pass. Zero hydration errors.

Takeaways

Wrappers that only add variants are a code smell. If the wrapper doesn't add behavior (hooks, state, API calls), the variants belong in the library. The "app-specific" label was just an excuse to avoid touching the shared layer.

Defaults should encode the dominant pattern. When every consumer overrides a default, the default is wrong. Changing center=true to center=false in Section eliminated 15 identical prop overrides.

The Rule of Three applies to deletion too. If three wrappers exist just to flip a prop, delete all three and fix the source.

Enforce at the tool level. A lint rule catches more violations than a convention document. The no-raw-headings rule has already prevented regressions in the same session it was introduced.