Responsive Mobile Website Markup
Responsive markup is not adding max-width: 100% to images. It's designing interface that works correctly from 320px to 2560px+, with different pixel densities, different input methods (touch vs mouse), different network conditions.
Mobile-First: Why Order Matters
Mobile-first means base styles written for mobile, extended via min-width media queries. Alternative — Desktop-first with max-width — harder to maintain due to cascading style overrides.
/* Mobile-first: base styles for mobile */
.card-grid {
display: grid;
grid-template-columns: 1fr; /* 1 column on mobile */
gap: 16px;
}
/* Expand for tablet */
@media (min-width: 640px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
}
/* Expand for desktop */
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
}
Breakpoints
Breakpoints should be determined by content, not devices. No point targeting specific iPhone models — each generation has different sizes.
Typical system:
| Name | Value | Context |
|---|---|---|
xs |
320px | Minimum supported size |
sm |
640px | Large phones, small tablets |
md |
768px | Tablets portrait |
lg |
1024px | Tablets landscape, small laptops |
xl |
1280px | Laptops, desktops |
2xl |
1536px | Wide screens |
In CSS variables or Sass:
:root {
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
}
Viewport and Units
<!-- Mandatory meta tag -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Without this tag, mobile browser renders page as 980px wide and scales it — media queries don't work as expected.
dvh instead of vh for mobile:
/* vh doesn't account for browser address bar on mobile */
.hero { min-height: 100vh; } /* May be cut off */
/* dvh — dynamic viewport height */
.hero { min-height: 100dvh; } /* Correct on mobile */
svh / lvh — small viewport height (address bar hidden) and large viewport height (visible). dvh changes on scroll, svh/lvh fixed extremes.
Fluid Typography
/* Fluid typography without media queries */
:root {
--text-base: clamp(1rem, 0.85rem + 0.75vw, 1.125rem);
--text-lg: clamp(1.125rem, 1rem + 0.625vw, 1.375rem);
--text-xl: clamp(1.375rem, 1rem + 1.875vw, 2rem);
--text-2xl: clamp(1.75rem, 1.25rem + 2.5vw, 2.75rem);
--text-3xl: clamp(2rem, 1.25rem + 3.75vw, 3.5rem);
}
body { font-size: var(--text-base); }
h1 { font-size: var(--text-3xl); }
h2 { font-size: var(--text-2xl); }
clamp(min, preferred, max) — font scales smoothly between min and max based on screen width.
Touch Targets
On mobile clickable area should be at least 44×44px (Apple HIG) or 48×48px (Google Material). Visual size can be smaller:
.icon-btn {
width: 24px;
height: 24px;
position: relative;
cursor: pointer;
}
/* Expand touch zone without changing visual */
.icon-btn::before {
content: '';
position: absolute;
inset: -12px; /* Expand 12px each side = 48×48px zone */
}
Or via padding:
.nav__link {
display: inline-flex;
align-items: center;
padding: 12px 16px; /* Sufficient touch zone */
min-height: 44px;
}
Responsive Images
<!-- art direction: different crops for different sizes -->
<picture>
<source
media="(min-width: 1024px)"
srcset="hero-desktop.webp 1440w, hero-desktop-2x.webp 2880w"
sizes="100vw"
>
<source
media="(min-width: 640px)"
srcset="hero-tablet.webp 768w, hero-tablet-2x.webp 1536w"
>
<img
src="hero-mobile.webp"
srcset="hero-mobile.webp 390w, hero-mobile-2x.webp 780w"
alt="Hero image"
width="390"
height="520"
loading="eager"
fetchpriority="high"
>
</picture>
For all other images — srcset + sizes:
<img
src="product-400.webp"
srcset="product-400.webp 400w, product-800.webp 800w, product-1200.webp 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt="Product name"
width="400"
height="400"
loading="lazy"
>
sizes tells browser what fraction of screen image will occupy at given width — allows correct file selection before CSS loads.
Responsive Navigation
Hamburger menu for mobile:
const Header: React.FC = () => {
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="header">
<a href="/" className="header__logo">Logo</a>
{/* Desktop navigation */}
<nav className="header__nav header__nav--desktop" aria-label="Main navigation">
<NavLinks />
</nav>
{/* Mobile toggle */}
<button
className="header__burger"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen(o => !o)}
>
<BurgerIcon open={menuOpen} />
</button>
{/* Mobile menu */}
<nav
id="mobile-menu"
className={`header__nav header__nav--mobile ${menuOpen ? 'is-open' : ''}`}
aria-hidden={!menuOpen}
>
<NavLinks onClose={() => setMenuOpen(false)} />
</nav>
</header>
);
};
.header__nav--desktop {
display: none;
}
.header__burger {
display: flex;
}
@media (min-width: 1024px) {
.header__nav--desktop { display: flex; }
.header__burger { display: none; }
.header__nav--mobile { display: none !important; }
}
.header__nav--mobile {
position: fixed;
inset: 0;
background: var(--color-bg-primary);
transform: translateX(100%);
transition: transform 300ms ease;
}
.header__nav--mobile.is-open {
transform: translateX(0);
}
Mobile Performance
Mobile networks slower than desktop. Critical:
-
Lazy loading for everything below fold:
loading="lazy"on images - Font subsetting: Cyrillic subset sufficient, no need full font
-
CSS containment:
contain: layout painton sections for render speed - Preload only critical first-screen resources
<link rel="preload" href="hero-mobile.webp" as="image"
imagesrcset="hero-mobile.webp 390w, hero-mobile-2x.webp 780w"
imagesizes="100vw"
media="(max-width: 639px)">
Timeframe
| Work Type | Time |
|---|---|
| Adapt desktop markup for mobile | 1–2 days |
| Mobile-first landing page | 2–3 days |
| Mobile-first corporate site | 4–6 days |







