We've rebuilt the same responsive layout about four times in the last two years. Not because we got bored — because the first three versions collapsed the moment the product got complicated. A new sidebar here, a collapsible nav there, a dashboard that needed to work on a 1280px laptop and a 4K monitor and also somehow a tablet. The classic breakpoint-soup approach fell apart every time.
What follows is the set of patterns we actually use now. Not the ones in tutorials where the screen is always a clean empty rectangle. The ones that survived contact with real products.
Stop Thinking in Breakpoints, Start Thinking in Components
The mental model shift that changed everything for us: breakpoints should be a last resort, not the first tool. When you write `@media (max-width: 768px)`, you're making a layout decision based on viewport size. But viewport size is a terrible proxy for what actually matters — available space for a component.
A card grid in a full-width layout has different space than the exact same card grid sitting inside a sidebar. Same viewport, completely different layout needs. CSS Container Queries solve this directly, and they're now supported in every modern browser.
/* Define a containment context */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* Style based on the CONTAINER's width, not viewport */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
}
}
@container card (max-width: 399px) {
.card {
display: flex;
flex-direction: column;
}
}With Tailwind, you can use container queries via the `@tailwindcss/container-queries` plugin or the built-in support in Tailwind v4. The syntax is `@container` and `@lg:` style variants. Once you start using this, you'll wonder how you survived without it — especially when the same component appears in multiple contexts across your app.
The Layout Shell Pattern
Most SaaS dashboards have the same problem: a shell (sidebar + topbar + main content area) that needs to respond differently at different sizes. The naive approach is to hide the sidebar on mobile with `hidden md:block` and call it a day. That works until you have 6 months of features in that sidebar and mobile users actually need to navigate.
What we do instead is separate the shell into three distinct behaviors that compose cleanly:
- Desktop (1024px+): sidebar is always visible, fixed on the left, content fills remaining space
- Tablet (768px–1023px): sidebar collapses to icons only, expands on hover/focus
- Mobile (<768px): sidebar is a drawer, triggered by a hamburger button, sits above content
// app/dashboard/layout.tsx
import { SidebarProvider } from '@/components/sidebar-context';
import { Sidebar } from '@/components/sidebar';
import { MobileNav } from '@/components/mobile-nav';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<div className="flex h-screen overflow-hidden">
{/* Desktop: always visible */}
<Sidebar className="hidden lg:flex" />
{/* Tablet: icon-only collapsed sidebar */}
<Sidebar collapsed className="hidden md:flex lg:hidden" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Mobile: topbar with drawer trigger */}
<MobileNav className="flex md:hidden" />
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
{children}
</main>
</div>
</div>
</SidebarProvider>
);
}The key detail: these aren't three separate components with duplicated logic. `Sidebar` accepts a `collapsed` prop and `MobileNav` renders the same navigation data as a drawer. The data and behavior is shared; only the presentation differs. This keeps your navigation in sync without maintaining parallel codebases.
Fluid Typography That Doesn't Make You Do Math
Responsive typography is usually handled one of two ways: either you set font sizes at each breakpoint (verbose, brittle), or you use `vw` units and get text that's either huge on desktop or microscopic on mobile. The modern approach is `clamp()`, and it's one of those CSS features that genuinely makes you happy.
/* clamp(minimum, preferred, maximum) */
/* Text scales between 1rem and 1.5rem, viewport-responsive in between */
.heading-xl {
font-size: clamp(1.5rem, 3vw + 0.5rem, 2.5rem);
line-height: 1.2;
}
.body-text {
font-size: clamp(0.9rem, 1vw + 0.7rem, 1.1rem);
line-height: 1.6;
}
/* For spacing too — padding that scales with viewport */
.section {
padding: clamp(2rem, 5vw, 6rem) clamp(1rem, 3vw, 3rem);
}In a Tailwind setup, you'd put these in your `@layer base` or as custom utilities. The `3vw + 0.5rem` preferred value is the part that does the work — it creates a linear scale between your min and max sizes across the viewport range you care about. You can use the Fluid Type Scale Calculator by Utopia to generate these values without doing the algebra yourself.
If you find yourself writing the same font size override at three different breakpoints, clamp() is what you actually want.
Grid Layouts That Don't Need Media Queries At All
This is the one we show people and they immediately go back and refactor their existing code. CSS Grid with `auto-fill` and `minmax` creates genuinely responsive layouts with zero breakpoints.
/* Cards that are at least 280px wide, fill available space */
/* 1 column on mobile, auto-fills as space grows — no @media needed */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* For a layout that wants at most 3 columns but is responsive */
.feature-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(min(100%, 320px), 1fr)
);
gap: 2rem;
}
/* The min() trick ensures the column never overflows its container */
/* Useful when the grid is inside a narrow container */
The `min(100%, 320px)` trick is the one that took us embarrassingly long to learn. Without it, if the container is 300px wide, `minmax(320px, 1fr)` creates a single column that overflows by 20px. Adding `min()` tells the browser: use 320px OR the full container width, whichever is smaller. The grid stays inside its container. Always.
In Tailwind terms: `grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-6`. Yes, it's verbose in the arbitrary value syntax. Put it in a component or a `@apply` class and move on.
Handling Tables on Mobile Without Losing Data
Tables are the responsive design villain nobody talks about. You have a data table with 8 columns. On mobile it's unreadable. The usual solutions are either terrible (horizontal scroll that the user doesn't know exists) or lossy (just hide columns and hope they weren't important).
We use a pattern we call the card-flip: on mobile, each table row becomes a card that shows label-value pairs vertically. It's a bit of CSS and some data attributes.
/* Mobile-first: rows become cards */
@media (max-width: 767px) {
.responsive-table thead {
display: none; /* Hide the header row */
}
.responsive-table tr {
display: block;
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.responsive-table td {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border: none;
}
/* Show the column label using data-label attribute */
.responsive-table td::before {
content: attr(data-label);
font-weight: 600;
color: var(--muted-foreground);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
/* Desktop: normal table */
@media (min-width: 768px) {
.responsive-table {
width: 100%;
border-collapse: collapse;
}
}The `data-label` attribute on each `<td>` is the key — it pulls the column name into the pseudo-element so users know what they're looking at. In React you'd set this when rendering: `<td data-label={column.header}>`. It's low-tech, but it works everywhere and doesn't require a JS-heavy table library.
The Aspect Ratio Trap (and How to Avoid It)
One pattern that bites teams constantly: hero images and media with fixed aspect ratios that look fine on desktop, awful on mobile. The old padding-top hack (`padding-top: 56.25%`) for 16:9 works but is cursed CSS that confuses everyone who reads it later.
/* Modern approach: CSS aspect-ratio property */
.video-embed {
width: 100%;
aspect-ratio: 16 / 9;
}
.profile-image {
width: 100%;
max-width: 400px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 50%;
}
/* For hero sections: let the aspect ratio change at different sizes */
.hero-image {
width: 100%;
aspect-ratio: 21 / 9; /* Cinematic on desktop */
}
@media (max-width: 767px) {
.hero-image {
aspect-ratio: 4 / 3; /* Taller on mobile */
}
}
The real power here is combining `aspect-ratio` with Next.js `Image` component's `fill` prop. You size the container with aspect-ratio, set `position: relative`, and let Next.js handle the actual image sizing and optimization inside it. No magic numbers, no layout shift, no broken images on weird viewport sizes.
// Clean image container pattern with Next.js
import Image from 'next/image';
export function HeroImage({ src, alt }: { src: string; alt: string }) {
return (
<div
className="
relative w-full overflow-hidden rounded-xl
aspect-[21/9] md:aspect-[21/9]
aspect-[4/3]
"
// Tailwind v3: use responsive variants
// aspect-[4/3] md:aspect-[21/9]
>
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
priority
/>
</div>
);
}That `sizes` prop on the Image component is doing real work — it tells the browser which source to download based on viewport size. Most people set `sizes="100vw"` and leave free performance on the table. Take the 2 minutes to set it correctly.
Putting It Together: A Practical Checklist
When we review responsive layouts — whether in our own code or across the templates at peal.dev — this is roughly the mental checklist we run through:
- Does this component know about the viewport, or should it know about its container? If the latter, reach for container queries.
- Is this grid layout using fixed breakpoints that could be replaced with auto-fill/minmax?
- Are font sizes and spacing using clamp() for smooth scaling, or jumping between fixed values?
- Do tables degrade gracefully on narrow screens, or do they overflow?
- Are images using the Next.js Image component with correct sizes attribute?
- Does the navigation work on mobile without hiding important features?
- Have you tested at 320px width? (Old iPhone SE, but also a lot of Android phones with browser chrome)
The goal isn't zero media queries — it's writing them intentionally rather than reactively. Most layout problems can be solved with fluid techniques first. Media queries are for the cases where you genuinely need a different design, not just different sizing.
Responsive design in 2025 isn't about learning new properties as much as unlearning the reflex to reach for breakpoints first. Container queries, clamp(), auto-fill grids — these tools have been available for a while. The shift is in when you choose to use them versus the media query hammer.
If you want to see how we apply this in practice, every template on peal.dev is built with these patterns from the start — dashboard shell, responsive data tables, fluid typography, the works. It's easier to start with the right foundation than to bolt responsive behavior onto an existing layout at 2am before a launch.
One last thing: test on real devices, not just browser DevTools. DevTools responsive mode lies to you in small but important ways — touch targets feel different, font rendering differs, and scroll behavior on iOS Safari has opinions that no amount of CSS will fully predict. Keep an old phone around. Use it. Your mobile users will thank you.
