TypeScript

Component-Driven Design with React, TypeScript, and Storybook

12 min readBy Joseph Edmonds

Every frontend codebase eventually accumulates a graveyard of slightly-different buttons. One is blue, one is navy, one has a drop shadow that nobody can explain, and three of them have margin-top: 3px applied directly in the page file because the designer asked for "just a small tweak" six months ago. Component-driven design is the discipline that stops the graveyard from forming. Pair React with TypeScript and you can enforce that discipline mechanically. It stops being a convention people remember to follow and becomes a rule the build refuses to break.

The Component Mindset

The core idea is older than React. Back in the mid-2000s, the industry built pages: large HTML documents with styles and behaviour bolted on. Everything on such a page is a special case by default. Your hero banner differs subtly from every other hero banner. Navigation on the product page carries one extra link the blog page lacks. Consistency, if you get it at all, comes from human discipline and shared CSS files that grow without bound.

Component-driven design inverts the model. You design the pieces first and compose pages from them, rather than designing pages and then trying to extract shared parts afterwards. A button is a button everywhere. A card is a card everywhere. A page header is a component with declared properties, and the page itself is a component that assembles other components.

React took this model mainstream for the web in 2013. Layer TypeScript on top and the informal "we agreed buttons should look like this" hardens into a compile-time contract. The sections below explain why that contract matters and how to structure it so it actually holds.

TypeScript Props as a Design Contract

A React component's props interface is a machine-readable specification of everything the component can do. Define a Button in TypeScript and the interface lists every dimension of variation the button supports, and nothing else.

Here is a typed button that accepts a variant and a size:

// The constrained Button: only accepts declared props — no escape hatches.
// Every valid visual state is enumerable from the type definitions alone.

type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps {
  variant: ButtonVariant;
  size: ButtonSize;
  label: string;
  onClick?: () => void;
  disabled?: boolean;
}

const variantStyles: Record<ButtonVariant, string> = {
  primary:
    'bg-blue-600 text-white hover:bg-blue-700 border border-blue-600',
  secondary:
    'bg-white text-blue-600 hover:bg-blue-50 border border-blue-600',
  ghost:
    'bg-transparent text-blue-600 hover:bg-blue-50 border border-transparent',
};

const sizeStyles: Record<ButtonSize, string> = {
  sm: 'px-3 py-1 text-sm rounded',
  md: 'px-4 py-2 text-base rounded-md',
  lg: 'px-6 py-3 text-lg rounded-lg',
};

export function Button({ variant, size, label, onClick, disabled = false }: ButtonProps) {
  const classes = [
    'inline-flex items-center justify-center font-medium transition-colors',
    variantStyles[variant],
    sizeStyles[size],
    disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
  ].join(' ');

  return (
    <button className={classes} onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

The compiler now enforces your design system. Pass variant="danger" and the build fails, because "danger" is not a declared variant. Pass a number to size and the build fails too. This contract is not a guideline buried in a Notion document that new developers might never read. It is a gate the build will not pass without satisfying.

That changes where design inconsistencies surface. They used to slip through to a design review, or worse, to production. Now they get caught the moment a developer saves the file. The TypeScript interface is the design spec, written in a language both the build tool and the developer can reason about precisely.

DRY: One Component, Infinite Reuse

People usually discuss "Don't Repeat Yourself" in the context of logic. Find yourself copy-pasting a function, extract it. The same principle applies with equal force to UI. If you are copy-pasting a button, that button is screaming to become a component.

The payoff matches the function case exactly. Change the button once and every instance updates. Fix an accessibility issue in the component and it is fixed everywhere. Update the hover colour for a rebrand and a single file changes. The alternative is twenty copies of the button spread across twenty page files, which means twenty manual edits, twenty chances to miss one, and twenty variations drifting apart over time.

A quieter benefit follows from this. When a piece of UI lives in only one place, decisions about it happen in only one place. Designers and developers have one thing to discuss, not twenty. "The button should have more padding" becomes a one-line change to a single component, not a refactoring sprint.

Styling by Flags: The Rule That Keeps Everything Sane

One rule separates a component library that stays coherent from one that slowly dissolves into chaos: components accept styling flags, never raw CSS.

Here is what the anti-pattern looks like:

// ANTI-PATTERN: Accepting arbitrary className and style overrides defeats design-system constraints.
// This looks flexible but is actually a design system timebomb.

interface BadButtonProps {
  label: string;
  onClick?: () => void;
  // ❌ These props hand visual control to every caller: within weeks you have
  // forty subtly different buttons, none tested, none documented, none consistent.
  className?: string;
  style?: React.CSSProperties;
}

export function BadButton({ label, onClick, className, style }: BadButtonProps) {
  return (
    <button
      // className merging means the base styles and the override styles fight
      // each other. The "winner" depends on CSS specificity and import order,
      // two things that should never determine visual design.
      className={`btn-base ${className ?? ''}`}
      style={style}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

// Call-site drift after six months of unconstrained usage:
//
// <BadButton label="Save" className="text-red-500 px-8 font-bold" />
// <BadButton label="Cancel" style={{ marginTop: '3px', color: '#1a1a2e' }} />
// <BadButton label="Delete" className="btn-danger override-red" style={{ fontSize: 13 }} />
//
// Each of those is a one-off decision buried in page code, invisible to
// designers, untested, and impossible to update globally.

Those className and style props look like flexibility. They are the exact mechanism by which design systems fall apart. The moment a consumer can pass arbitrary CSS, the component stops being the single source of truth for how a button looks. Every caller turns into a one-off override. Within months you have as many button variations as you have call sites, and none of them are documented, tested, or consistent.

Accept variant and size instead and the set of valid visual states becomes finite and declared. Three variants, three sizes, one disabled flag gives you eighteen combinations. Every one of them is enumerable, nameable, testable, and visible in Storybook. None of them hide in page-level CSS that only the developer who wrote it understands.

What to do when the design genuinely needs something new

The right response to "we need a red destructive button" is not to pass className="text-red-500 border-red-500" at the call site. Add 'destructive' to the ButtonVariant union type, define its styles in the lookup table, write a Storybook story for it, and update the tests. The new variant becomes a first-class, documented member of the design system rather than a one-off hack visible only to its author.

This forces a healthy friction. Before adding a new variant, someone has to consciously decide it belongs in the permanent design vocabulary. "Do we need a destructive button, or should we use the existing secondary button with a modal confirmation?" is exactly the conversation that design systems exist to prompt.

The testing argument

Beyond design consistency, there is a practical reason for the no-arbitrary-CSS rule: testability. When the set of valid states is finite and declared in the type system, you can write exhaustive tests mechanically. When states are arbitrary, meaning any combination of CSS classes and inline styles the caller might dream up, exhaustive testing becomes impossible by definition. You can only test what you thought to test.

Storybook: See Every State Before You Ship

Storybook renders components in isolation, outside the application. Each "story" is a single named state of a component, one specific combination of props. Open it and you get a browsable catalogue of every component in the system in every declared state, with no need to navigate through the app to find the right screen.

Since the Button's valid states are enumerable from its type definition, the stories file is almost mechanical to write:

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button-component';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'ghost'],
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg'],
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// --- Primary variant ---

export const PrimarySmall: Story = {
  args: { variant: 'primary', size: 'sm', label: 'Button' },
};

export const PrimaryMedium: Story = {
  args: { variant: 'primary', size: 'md', label: 'Button' },
};

export const PrimaryLarge: Story = {
  args: { variant: 'primary', size: 'lg', label: 'Button' },
};

export const PrimaryDisabled: Story = {
  args: { variant: 'primary', size: 'md', label: 'Button', disabled: true },
};

// --- Secondary variant ---

export const SecondarySmall: Story = {
  args: { variant: 'secondary', size: 'sm', label: 'Button' },
};

export const SecondaryMedium: Story = {
  args: { variant: 'secondary', size: 'md', label: 'Button' },
};

export const SecondaryLarge: Story = {
  args: { variant: 'secondary', size: 'lg', label: 'Button' },
};

export const SecondaryDisabled: Story = {
  args: { variant: 'secondary', size: 'md', label: 'Button', disabled: true },
};

// --- Ghost variant ---

export const GhostSmall: Story = {
  args: { variant: 'ghost', size: 'sm', label: 'Button' },
};

export const GhostMedium: Story = {
  args: { variant: 'ghost', size: 'md', label: 'Button' },
};

export const GhostLarge: Story = {
  args: { variant: 'ghost', size: 'lg', label: 'Button' },
};

export const GhostDisabled: Story = {
  args: { variant: 'ghost', size: 'md', label: 'Button', disabled: true },
};

You end up with a living document. A designer can open Storybook and see exactly what the "secondary, large, disabled" button looks like, with no developer needed to build a page that happens to contain one. When the spec says "the ghost button should look muted", there is a specific story to point at. When a developer implements the change, there is a specific story to verify against.

Stories double as regression anchors. A visual regression tool can screenshot every story automatically and flag anything that changes unexpectedly, so no component drifts silently between deploys.

Testing Is Easy When Possibilities Are Finite

Testing constrained components is refreshingly direct. Every valid state is a named combination of declared props, so a test suite can iterate over every combination systematically. The example below uses Vitest and React Testing Library, though the pattern works with any test runner:

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Button } from './button-component';

// Finite props mean every valid combination is enumerable: full test coverage without guessing.

const variants = ['primary', 'secondary', 'ghost'] as const;
const sizes = ['sm', 'md', 'lg'] as const;
const disabledStates = [false, true] as const;

describe('Button', () => {
  describe('renders every variant/size/disabled combination', () => {
    for (const variant of variants) {
      for (const size of sizes) {
        for (const disabled of disabledStates) {
          const label = `${variant}/${size}${disabled ? '/disabled' : ''}`;

          it(`renders ${label} without throwing`, () => {
            const { container } = render(
              <Button variant={variant} size={size} label="Test" disabled={disabled} />,
            );
            expect(container.firstChild).toBeTruthy();
          });

          it(`matches snapshot for ${label}`, () => {
            const { container } = render(
              <Button variant={variant} size={size} label="Test" disabled={disabled} />,
            );
            expect(container).toMatchSnapshot();
          });
        }
      }
    }
  });

  // TypeScript enforces at compile time that invalid props cannot be passed,
  // so there is no need to test for "what happens with an unknown variant" —
  // that scenario literally cannot be compiled.
});

Three variants, three sizes, one disabled flag, and the nested loop covers all eighteen combinations. No state goes untested because no state can exist outside the declared combinations. Add a new variant to the type and TypeScript flags every switch statement or lookup table that fails to handle it, which forces the developer to define its styles and its tests in the same change.

Now compare a component that accepts arbitrary className. Its test suite covers whatever the author thought to test. Its production cases are whatever consumers happen to pass. Nothing connects the two, and bugs live in the gap.

Composition: Components All the Way Up

The component model scales from atoms to pages. A Button is a primitive. A Card composes smaller sub-components and uses Button internally:

// Button is used via declared props only; no style overrides permitted.

import { Button } from './button-component';

type CardIntent = 'default' | 'featured' | 'muted';

interface CardHeaderProps {
  title: string;
  subtitle?: string;
}

interface CardProps {
  intent: CardIntent;
  header: CardHeaderProps;
  body: string;
  actionLabel?: string;
  onAction?: () => void;
}

const intentStyles: Record<CardIntent, string> = {
  default: 'bg-white border border-gray-200',
  featured: 'bg-blue-50 border border-blue-300 shadow-md',
  muted: 'bg-gray-50 border border-gray-100',
};

function CardHeader({ title, subtitle }: CardHeaderProps) {
  return (
    <div className="px-5 pt-5 pb-3">
      <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
      {subtitle && (
        <p className="mt-1 text-sm text-gray-500">{subtitle}</p>
      )}
    </div>
  );
}

function CardBody({ children }: { children: React.ReactNode }) {
  return (
    <div className="px-5 pb-4 text-sm text-gray-700 leading-relaxed">
      {children}
    </div>
  );
}

function CardFooter({ children }: { children: React.ReactNode }) {
  return (
    <div className="px-5 pb-5 pt-2 border-t border-gray-100">
      {children}
    </div>
  );
}

export function Card({ intent, header, body, actionLabel, onAction }: CardProps) {
  return (
    <div className={`rounded-xl overflow-hidden ${intentStyles[intent]}`}>
      <CardHeader title={header.title} subtitle={header.subtitle} />
      <CardBody>{body}</CardBody>
      {actionLabel && onAction && (
        <CardFooter>
          <Button
            variant={intent === 'featured' ? 'primary' : 'secondary'}
            size="sm"
            label={actionLabel}
            onClick={onAction}
          />
        </CardFooter>
      )}
    </div>
  );
}

Notice that the Card does not accept a buttonVariant prop and pass it through. It makes the design decision internally: featured cards get a primary button, all others get secondary. That decision lives in the component, not scattered across every page that renders a card. Change the rule and one file changes.

A ProductGrid composes multiple Card components. A CategoryPage composes ProductGrid with a PageHeader and a Sidebar. The page layout itself is a component too:

// Even the top-level layout is a named, testable, documented choice,
// not an ad-hoc CSS class scattered across route files.

type PageLayout = 'default' | 'full-width' | 'landing';

interface PageProps {
  layout: PageLayout;
  children: React.ReactNode;
}

const layoutStyles: Record<PageLayout, string> = {
  default: 'max-w-3xl mx-auto px-6 py-12',
  'full-width': 'w-full px-8 py-8',
  landing: 'max-w-6xl mx-auto px-6',
};

export function Page({ layout, children }: PageProps) {
  return (
    <main className={layoutStyles[layout]}>
      {children}
    </main>
  );
}

// Usage: the layout choice is declared at the route level, not buried in CSS:
//
// function ProductCataloguePage() {
//   return (
//     <Page layout="full-width">
//       <ProductGrid ... />
//     </Page>
//   );
// }
//
// function BlogPostPage() {
//   return (
//     <Page layout="default">
//       <ArticleDetail ... />
//     </Page>
//   );
// }
//
// function HomepagePage() {
//   return (
//     <Page layout="landing">
//       <HeroSection ... />
//       <FeatureGrid ... />
//       <CallToAction ... />
//     </Page>
//   );
// }

Even "this route uses a full-width layout" becomes a named, documented, testable choice rather than a CSS class on a <div> that some developer dropped into a route file.

This fractal structure makes consistency compound as the codebase grows. Every new page draws on components whose visual behaviour is already defined and tested. The only fresh decisions are structural ones: which components to use, in what order, with what data. The design vocabulary stays bounded and intentional instead of expanding with every feature.

Taken to its logical conclusion, a screen at the top of this hierarchy contains no raw HTML at all. Every element is a named component:

import type { ReactElement } from 'react';
import { useState } from 'react';
import { CalendarDays, Plus } from 'lucide-react';
import { ScreenContainer, ScreenHeader, PageSection, Stack } from '@/components/layout';
import { SectionHeader, EmptyState, AppointmentCard, ButtonGroup } from '@/components/composite';
import { Button } from '@/components/ui/button';

// Every JSX tag is a named component from the component library.
// The no-html-in-screens ESLint rule enforces this structurally:
// a <div> or <h2> in a screen file is a build error, not a convention to remember.

export function DashboardScreen({
  userName,
  appointments,
  onBookNew,
  onCancel,
}: DashboardScreenProps): ReactElement {
  const [showPast, setShowPast] = useState(false);
  const upcoming = appointments.filter(a => a.status === 'upcoming');
  const past = appointments.filter(a => a.status !== 'upcoming');

  return (
    <ScreenContainer maxWidth="lg">
      <ScreenHeader
        title={`Welcome back, ${userName}`}
        subtitle="Manage your upcoming appointments"
        actions={
          <Button onClick={onBookNew}>
            <Plus className="mr-2 h-4 w-4" />
            Book Appointment
          </Button>
        }
      />

      <PageSection>
        <SectionHeader title="Upcoming" />
        {upcoming.length === 0 ? (
          <EmptyState
            icon={CalendarDays}
            title="No upcoming appointments"
            action={{ label: 'Book now', onClick: onBookNew }}
          />
        ) : (
          <Stack spacing="3">
            {upcoming.map(appt => (
              <AppointmentCard
                key={appt.id}
                appointment={appt}
                actions={
                  <ButtonGroup>
                    <Button variant="ghost" size="sm" onClick={() => onCancel(appt.id)}>
                      Cancel
                    </Button>
                  </ButtonGroup>
                }
              />
            ))}
          </Stack>
        )}
      </PageSection>

      <PageSection>
        <SectionHeader
          title="Past Appointments"
          actions={
            past.length > 0 && (
              <Button variant="ghost" size="sm" onClick={() => setShowPast(!showPast)}>
                {showPast ? 'Hide' : `Show (${past.length})`}
              </Button>
            )
          }
        />
        {showPast && (
          <Stack spacing="3">
            {past.map(appt => (
              <AppointmentCard key={appt.id} appointment={appt} muted />
            ))}
          </Stack>
        )}
      </PageSection>
    </ScreenContainer>
  );
}

No <div>, no <h2>, no <p>. The screen is a wiring diagram — routing data to components that already know how to display it. Visual decisions live inside the components. The screen owns structure and data flow, and nothing else.

Component libraries formalise this into a five-tier hierarchy: primitives (buttons, inputs, badges), layout components (structural containers and spacing utilities), composite components (assembled patterns like a form field with its label and error message), feature components (domain-aware UI like a booking calendar or payment form), and screens (full pages wiring everything together). Dependencies run strictly downward: screens import from the feature and layout tiers, never the reverse.

Making the Rules Stick: ESLint as the Enforcer

Conventions erode. A rule documented in a README is forgotten by the third developer who joins, misunderstood by the fourth, and quietly ignored by the fifth. The no-arbitrary-CSS discipline, the five-tier hierarchy, the ban on raw HTML in screens — these only stay coherent if something enforces them mechanically: not guidelines people might follow, but gates the build will not pass without satisfying.

One effective approach treats every CDD violation as the trigger for a static analysis rule rather than just a one-time code fix. The pattern is called Defence Before Fix: when a violation appears, the first question is not "how do I fix this instance?" but "what rule would have caught this before it was written?" Create the lint rule first, then fix the instance. Next time, the rule catches it in the editor before it reaches review.

Applied to the composition hierarchy, this produces a rule that bans raw HTML in screen components entirely:

/**
 * ESLint rule: enforce that screen/page components use only named components,
 * never raw HTML elements. Screens must be pure composition.
 *
 * Part of the Defence Before Fix pattern: when a CDD violation was found
 * in a screen component, this rule was created so the entire class of
 * violation becomes a build-time error rather than a convention to remember.
 */

const BANNED_ELEMENTS = [
  'div', 'section', 'article', 'aside', 'main', 'header', 'footer', 'nav',
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span',
  'ul', 'ol', 'li',
  'form', 'input', 'textarea', 'label', 'select', 'button', 'a',
];

export default {
  meta: {
    type: 'problem',
    messages: {
      noHtmlInScreens:
        '🚫 Raw HTML is banned in screen components: <{{element}}> is not allowed here.\n' +
        'Screens must be composed entirely of named components from src/components/.\n' +
        'Suggested alternatives:\n' +
        '  • <div> / <section>  →  ScreenContainer, PageSection, Card\n' +
        '  • <h1> / <h2>        →  ScreenHeader, SectionHeader\n' +
        '  • <p> / <span>       →  Text, Heading components\n' +
        '  • <button>           →  Button from your component library\n' +
        '  • <form> / <input>   →  FormField, FormSection components\n' +
        'If the right component does not exist yet, create it.',
    },
  },

  create(context) {
    return {
      JSXElement(node) {
        const filename = context.filename ?? context.getFilename();
        const isScreen = filename.includes('/src/screens/') && filename.endsWith('.tsx');

        // Story files are exempt: they require wrapper elements for layout demonstration.
        if (!isScreen || filename.endsWith('.stories.tsx')) return;

        const elementName = node.openingElement.name.name;
        if (BANNED_ELEMENTS.includes(elementName)) {
          context.report({
            node: node.openingElement,
            messageId: 'noHtmlInScreens',
            data: { element: elementName },
          });
        }
      },
    };
  },
};

The rule scans every file in the screens directory and reports an error the moment a raw HTML element appears: <div>, <h1>, <p>, <form>, all of them. The error message does not just say "this is wrong"; it names the component to reach for instead. Screens stay pure composition by structural impossibility.

This pairs naturally with TypeScript's enforcement at the component boundary. TypeScript prevents invalid props; ESLint prevents raw HTML in screens. Together they make the architecture self-defending — the codebase pushes back on violations the moment they are written, before a pull request, before a review, before a test run. The discipline scales to teams of any size because it lives in tooling, not in institutional memory.

Designers Can Ship New Features Independently, and With High Confidence

Once a mature component library exists, a designer or front-end designer, someone who writes markup and basic React but is not a full engineer, can build an entire new page by composing components that already exist. No new components to build. No new styles to write. No new test cases to author. All of that already exists, and it is already passing.

The confidence that comes with this is the real prize. Every component on the new page has been battle-tested. It has Storybook documentation, snapshot tests covering every state, and visual regression coverage on top. Assemble those proven pieces and the new page inherits all of that rigour for free. A designer can ship something genuinely new without a senior engineer hovering nervously over the diff, because there is no new untested UI in it to be nervous about.

This rewrites the economics of front-end work. "Can we add a campaign landing page?" stops being a multi-day engineering task and becomes an afternoon of composition.

A Shared Language Between Designers and Developers

The most underappreciated benefit of component-driven design is linguistic. Give every piece of UI a name (Button, Card, PageLayout) and every valid state of that piece a name (primary, featured, full-width), and designers and developers can finally have precise conversations. "The featured card on mobile" means something specific and findable. "Make the ghost button larger" is a one-word change to a single prop.

The TypeScript interface is not an implementation detail. It is the vocabulary of the design system, written in a form a compiler can enforce. Grow that interface thoughtfully, adding new variants deliberately and deprecating old ones explicitly, and the design system stays coherent over years and across teams. It holds up long after launch, not just for the few weeks while everyone still remembers why.

That scattered CSS nightmare is not inevitable. It is simply what happens when UI decisions get made locally, case by case, with no shared vocabulary and no machine-enforced contract. Component-driven design, typed with TypeScript and documented with Storybook, is the structural answer. Commit to it and you build something that gets stronger with every feature instead of more fragile: a codebase where shipping fast and shipping safely stop being in tension, and where the next page is always the easiest one you have built. That is worth the up-front discipline many times over.