EmpireUI
Get Pro
← Blog8 min read#responsive-design#design-systems#mobile-first

Responsive Design Systems: Mobile-First Component Variants

Mobile-first component variants that actually scale. A developer's guide to building responsive design systems with Tailwind v4 and React — without the bloat.

Developer working on responsive UI components across multiple screen sizes on a dark monitor

Mobile-First Is Still the Right Default

Honestly, most design systems get mobile-first backwards. They start with a desktop layout, slap on some sm: and md: prefixes, and call it responsive. What you end up with is a component that fights itself at 375px width.

Mobile-first isn't a constraint — it's a forcing function. When you design a card component for a 390px viewport first, you're making real decisions about information hierarchy. What's actually important enough to show? What can live behind a tap? Those decisions make the desktop experience better too, not just the mobile one.

The shift matters most in component-level thinking. A nav component on desktop is a horizontal list of links. On mobile, it's a hamburger menu, a drawer, a bottom sheet — something fundamentally different. Treating these as one component with responsive tweaks is a recipe for prop-spaghetti. They might share a data contract, but they're different UI surfaces.

Defining Breakpoints That Match Real Devices

Tailwind v4.0.2 keeps the default breakpoints at sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px. These are reasonable starting points, but they're not sacred. Your product's analytics will tell you a lot more about which breakpoints actually matter for your users.

If 60% of your traffic lands between 390px and 430px — which is iPhone 14/15 territory — you might want a custom xs: 390px breakpoint for fine-grained control without hacking the sm: namespace. In Tailwind v4, extending the theme is cleaner than ever using the CSS-first config approach.

What you want to avoid is breakpoint sprawl. Four or five breakpoints is usually the ceiling before your utility classes become unreadable. sm:text-sm md:text-base lg:text-lg xl:text-xl on every heading is a sign the typography system needs a scale, not more breakpoints. Check out how to build a spacing system in CSS — the same principles apply to type sizing.

Component Variants vs Responsive Props: Pick the Right Tool

There are two broad approaches to responsive components in React. You either pass responsive props like size={{ base: 'sm', md: 'lg' }}, or you use CSS to swap styles at breakpoints. Both work. They don't work equally well in every situation.

Responsive props are great for layout orchestration from a parent — a Grid component deciding column count, a Stack deciding whether to go horizontal or vertical. The parent knows the layout context better than the child does. Libraries like Chakra UI popularized this pattern, and it reads cleanly in JSX.

Pure CSS breakpoints are better for self-contained components. A Button doesn't need to know if it's on mobile. It just needs to have the right touch target size (44px minimum, per WCAG — see the full accessibility guide here). If the button's styles are all in CSS with Tailwind utilities, there's no JavaScript bundle cost for the responsive behavior. Prefer that where you can.

Building a Mobile-First Card Component in Tailwind

Let's make this concrete. Here's a card component that stacks vertically on mobile and goes side-by-side on md: and above. The image fills the top on mobile and snaps to the left on wider screens.

interface CardProps {
  image: string;
  title: string;
  description: string;
  tag?: string;
}

export function ResponsiveCard({ image, title, description, tag }: CardProps) {
  return (
    <div className="flex flex-col md:flex-row gap-4 md:gap-6 rounded-2xl overflow-hidden bg-white/5 border border-white/10 backdrop-blur-sm">
      <div className="w-full md:w-48 md:shrink-0 aspect-video md:aspect-auto">
        <img
          src={image}
          alt={title}
          className="w-full h-full object-cover"
        />
      </div>
      <div className="flex flex-col justify-center gap-2 p-4 md:p-6">
        {tag && (
          <span className="text-xs font-medium text-violet-400 uppercase tracking-wider">
            {tag}
          </span>
        )}
        <h3 className="text-base md:text-lg font-semibold text-white leading-snug">
          {title}
        </h3>
        <p className="text-sm text-white/60 leading-relaxed">
          {description}
        </p>
      </div>
    </div>
  );
}

A few things worth noting. The gap-4 md:gap-6 pattern — 16px base gap expanding to 24px on wider screens — is a small detail that pays off. Tight gaps feel cramped on desktop; loose gaps waste space on mobile. The aspect-video md:aspect-auto swap is doing real work too, giving the image a sensible height on mobile without a hardcoded pixel value.

Container Queries: When Breakpoints Aren't Enough

Here's the thing: viewport breakpoints break down in component-driven architectures. A card in a two-column grid is narrower than a card in a one-column grid, even at the same viewport width. Your md: breakpoint fires the same way for both, which is often wrong.

Container queries solve this. Instead of @media (min-width: 768px), you write @container (min-width: 400px) and the component responds to its own available width. Tailwind v4 ships with container query support out of the box — use @container on the parent and @md: (container variant) on children.

/* In your globals.css or component stylesheet */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

/* Component responds to its container, not the viewport */
@container card (min-width: 480px) {
  .card-inner {
    flex-direction: row;
    gap: 24px; /* 24px = 6 * 4px base unit */
  }

  .card-image {
    width: 160px;
    flex-shrink: 0;
  }
}

Container queries pair especially well with design tokens. When your spacing values come from a token (--space-6: 24px) rather than a hardcoded 24px, you can swap the entire scale for a compact mode without touching every component. That's a real win for component libraries — which is something we lean on heavily in Empire UI.

Managing Responsive Tokens in Your Design System

Responsive design systems live or die by their token architecture. If your font sizes, spacing, and radii are hardcoded numbers scattered across components, you'll spend half your time hunting down the 14 places where border-radius: 8px was written by hand.

A token-based approach centralizes those decisions. Your --radius-card token might be 8px on mobile and 12px on desktop. Change it in one place and every card in the system updates. Combined with a solid color system design that also uses tokens, you're building something that's actually maintainable.

Don't over-engineer it on day one. Start with spacing, type scale, and radii. Get those right and the rest follows. A 4px base grid (so values are always multiples of 4: 4px, 8px, 12px, 16px, 24px, 32px, 48px) keeps things harmonious without requiring a spreadsheet to understand your padding choices.

If you're sharing tokens between a Figma file and your codebase, the Figma to React workflow article covers the token sync tooling in detail. Style Dictionary and Token Studio are the main options right now, and the choice between them matters more than you'd think.

Testing Responsive Components Without Losing Your Mind

Manual browser resizing is not a testing strategy. It works for one-off checks, but it doesn't scale when you've got 40+ components and three breakpoints each. You need a structured approach.

Storybook with the viewport addon is the most practical option. Define your breakpoints once in .storybook/preview.js, and every story gets a viewport switcher for free. You can see your ResponsiveCard at 390px, 768px, and 1280px in seconds. For deeper coverage, the Storybook component library setup guide walks through the full configuration.

Visual regression testing catches things manual review misses — a 2px layout shift that you'd never notice at a glance but that breaks alignment across the grid. Tools like Chromatic (Storybook's cloud service) or Percy integrate with CI and flag pixel-level changes. Worth the setup cost if you're shipping a shared component library. Are you really going to catch that 1px border-radius regression in code review? Probably not.

Pitfalls That Will Actually Bite You

Hidden overflow on mobile is the silent killer of responsive layouts. You build a beautiful horizontal card that looks perfect on desktop, push to production, and discover that on a 375px screen the card bleeds 40px past the right edge. Always run your components in a constrained container during development. A wrapper with max-width: 390px and overflow: hidden on your dev preview saves real headaches.

Touch targets are another one. The 44x44px minimum from Apple's HIG and WCAG 2.5.5 isn't optional if you care about usability. Small icon buttons, tight navigation tabs, close icons on modals — these are the usual offenders. Add min-h-[44px] min-w-[44px] to interactive elements and you're most of the way there.

CSS specificity fights are common when you're mixing responsive utilities with component-level overrides. In Tailwind v4, the cascade layer system helps significantly. But if you're seeing utilities lose to component styles, check whether the component CSS is importing outside a @layer components block. The ordering matters, and it's one of those bugs that looks impossible until you understand exactly why it's happening.

Finally — and this one's specific to Next.js apps — server-side rendering and responsive components don't always mix cleanly. If a component renders differently based on screen size and you're doing that in JavaScript (not CSS), you'll get hydration mismatches. Prefer CSS-driven responsiveness wherever possible. Save JS-driven viewport detection for genuinely different data fetching needs, not just style differences.

FAQ

Should I use `useMediaQuery` in React or handle responsiveness purely in CSS?

Default to CSS unless you have a specific reason not to. CSS breakpoints have zero JavaScript bundle cost and avoid hydration mismatches in SSR environments like Next.js. Use useMediaQuery when the difference between screen sizes involves different data fetching, component mounting, or logic that can't be expressed in CSS — not just visual differences.

What's the difference between container queries and viewport breakpoints, and when should I use each?

Viewport breakpoints respond to the full browser width. Container queries respond to the component's available width. Use viewport breakpoints for page-level layout (sidebar visible or hidden, nav collapsed or expanded). Use container queries for reusable components that might appear in different layout contexts — a card that's full-width in one place and one-third-width in another.

How do I handle responsive font sizes without a million Tailwind utility classes?

Use CSS clamp() for fluid type scaling. Something like font-size: clamp(1rem, 2.5vw + 0.5rem, 1.25rem) scales smoothly between minimum and maximum sizes without any breakpoints at all. In Tailwind v4, you can register this as a theme token so it's available as a utility class. Avoids the text-sm md:text-base lg:text-lg chain entirely.

My Tailwind responsive variants aren't applying on mobile. What's usually wrong?

Check your <meta name='viewport'> tag first — without content='width=device-width, initial-scale=1', mobile browsers render at a virtual 980px width and your sm: utilities fire unexpectedly. Second, remember that Tailwind is mobile-first, so unprefixed utilities apply everywhere and sm: applies at 640px and above. If you want mobile-only styles, apply them without a prefix and override at sm:.

How many breakpoints should a design system define?

Four is a reasonable ceiling for most products. sm (640px), md (768px), lg (1024px), xl (1280px) covers the vast majority of cases. Adding more breakpoints multiplies the testing surface without proportional benefit. If you find yourself needing more precision, container queries are usually the better answer than a fifth or sixth viewport breakpoint.

How do I test that my responsive components are accessible at all breakpoints?

Run axe or Lighthouse at each major breakpoint — the accessibility violations are often different. A button that has a proper label on desktop might have its text hidden by a responsive truncation class on mobile, failing the accessible name check. In Storybook, the a11y addon runs automatically per story so you can catch issues per viewport.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Component State Design: Default, Hover, Active, Disabled, ErrorSpacing Scale Design: T-shirt Sizes vs Fibonacci vs 8pt GridTailwind Responsive Design: Beyond Mobile-First, Container QueriesMonochrome UI Design: One-Color Systems That Look Expensive