Web Styling
Introduction
Styling approaches significantly impact code maintainability, bundle size, and development velocity. This guide covers modern CSS strategies from CSS Modules to utility-first frameworks, responsive design patterns, theming systems, and design system integration.
CSS Approaches
Multiple approaches exist for styling components, each with distinct tradeoffs in maintainability, performance, and developer experience.
CSS Modules
CSS Modules scope styles to individual components by automatically generating unique class names. This prevents global namespace pollution and accidental style overrides.
How CSS Modules Work:
When you import a CSS Module file, the build tool processes it and returns an object mapping original class names to scoped versions:
/* PaymentCard.module.css */
.card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem;
}
.amount {
font-size: 1.5rem;
font-weight: 700;
color: #2c3e50;
}
.statusPending {
color: #f39c12;
}
.statusCompleted {
color: #27ae60;
}
// PaymentCard.tsx
import styles from './PaymentCard.module.css';
export function PaymentCard({ payment }: { payment: Payment }) {
return (
<div className={styles.card}>
<div className={styles.amount}>
{formatCurrency(payment.amount)}
</div>
<div className={styles[`status${payment.status}`]}>
{payment.status}
</div>
</div>
);
}
// Generated HTML (simplified):
// <div class="PaymentCard_card__a3x2z">
// <div class="PaymentCard_amount__b4y3a">$100.00</div>
// <div class="PaymentCard_statusCompleted__c5z4b">Completed</div>
// </div>
Benefits:
- Scoped Styles: No global namespace collisions
- Explicit Dependencies: Imports show which styles a component uses
- Dead Code Elimination: Unused styles can be detected and removed
- Co-location: Styles live next to components they style
Composition:
CSS Modules support composition to share styles between selectors:
/* Button.module.css */
.base {
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-weight: 600;
transition: all 0.2s;
border: none;
cursor: pointer;
}
.primary {
composes: base;
background-color: #2196F3;
color: white;
}
.primary:hover {
background-color: #1976D2;
}
.secondary {
composes: base;
background-color: #f5f5f5;
color: #333;
}
.secondary:hover {
background-color: #e0e0e0;
}
import styles from './Button.module.css';
<button className={styles.primary}>Submit</button>
<button className={styles.secondary}>Cancel</button>
Utility-First CSS (Tailwind)
Utility-first CSS uses small, single-purpose classes to build complex designs. Tailwind CSS is the most popular implementation.
Philosophy:
Instead of writing custom CSS for each component, compose utilities that handle spacing, colors, typography, and layout. This approach emerged from observing that many custom stylesheets contain similar patterns repeated with slight variations.
// Traditional approach - custom CSS
<button className="btn btn-primary btn-lg">
Submit Payment
</button>
// Utility-first approach - composed utilities
<button className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
Submit Payment
</button>
Benefits:
- No Naming: No need to invent class names like "payment-card-header-title"
- Co-location: Styles visible directly in markup
- Consistency: Design system constraints enforced through utilities
- Small Bundle: Purges unused utilities in production
- Rapid Prototyping: Build UIs quickly without leaving markup
Challenges:
- Visual Noise: Long class strings can be hard to read
- Learning Curve: Memorizing utility names takes time
- Markup Coupling: Changing styles requires changing markup
Extraction Pattern:
Extract repeated utility combinations into components:
// Before - repeated utilities
<button className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
Submit
</button>
<button className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
Confirm
</button>
// After - extracted component
export function PrimaryButton({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors"
{...props}
>
{children}
</button>
);
}
<PrimaryButton>Submit</PrimaryButton>
<PrimaryButton>Confirm</PrimaryButton>
Custom Utilities:
Extend Tailwind with custom utilities for repeated patterns:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
'brand-blue': '#2196F3',
'brand-dark-blue': '#1976D2',
},
spacing: {
'72': '18rem',
'84': '21rem',
'96': '24rem',
},
},
},
plugins: [
function({ addUtilities }) {
addUtilities({
'.card-shadow': {
'box-shadow': '0 2px 8px rgba(0, 0, 0, 0.1)',
},
'.card-shadow-hover': {
'box-shadow': '0 4px 16px rgba(0, 0, 0, 0.15)',
},
});
},
],
};
CSS-in-JS
CSS-in-JS writes styles in JavaScript/TypeScript, enabling dynamic styling based on props and state. Popular libraries include styled-components, Emotion, and Vanilla Extract.
styled-components Example:
import styled from 'styled-components';
interface ButtonProps {
$variant?: 'primary' | 'secondary' | 'danger';
$size?: 'small' | 'medium' | 'large';
}
const StyledButton = styled.button<ButtonProps>`
padding: ${props => {
switch (props.$size) {
case 'small': return '0.5rem 1rem';
case 'large': return '1rem 2rem';
default: return '0.75rem 1.5rem';
}
}};
background-color: ${props => {
switch (props.$variant) {
case 'danger': return '#D32F2F';
case 'secondary': return '#757575';
default: return '#2196F3';
}
}};
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
`;
export function Button({
variant = 'primary',
size = 'medium',
children,
...props
}: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<StyledButton $variant={variant} $size={size} {...props}>
{children}
</StyledButton>
);
}
Benefits:
- Dynamic Styling: Styles react to props and state
- Scoping: Styles automatically scoped to components
- TypeScript: Type-safe style props
- Theming: Easy theme provider integration
- Critical CSS: Automatically extracts critical styles
Challenges:
- Runtime Overhead: Some libraries have runtime costs (styled-components, Emotion)
- Bundle Size: Increases JavaScript bundle
- Learning Curve: Different mental model from traditional CSS
- Debugging: Generated class names harder to trace
Zero-Runtime CSS-in-JS:
Libraries like Vanilla Extract and Linaria extract styles at build time, avoiding runtime costs:
// Button.css.ts (Vanilla Extract)
import { style, styleVariants } from '@vanilla-extract/css';
const base = style({
padding: '0.75rem 1.5rem',
border: 'none',
borderRadius: '4px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
});
export const button = styleVariants({
primary: [base, {
backgroundColor: '#2196F3',
color: 'white',
}],
secondary: [base, {
backgroundColor: '#757575',
color: 'white',
}],
danger: [base, {
backgroundColor: '#D32F2F',
color: 'white',
}],
});
import * as styles from './Button.css';
<button className={styles.button.primary}>Submit</button>
Comparison Table
| Approach | Scoping | Dynamic | Runtime | Bundle Size | Learning Curve |
|---|---|---|---|---|---|
| CSS Modules | Auto-scoped | Limited | None | CSS only | Low |
| Tailwind | Manual | No | None | Small (purged) | Medium |
| CSS-in-JS (Runtime) | Auto-scoped | Yes | Medium | Larger | Medium |
| CSS-in-JS (Zero-runtime) | Auto-scoped | Limited | None | CSS only | High |
Responsive Design
Responsive design adapts layouts to different screen sizes, ensuring usability across devices. Modern approaches emphasize mobile-first design and fluid layouts.
Mobile-First Approach
Mobile-first design starts with the most constrained viewport (mobile) then progressively enhances for larger screens. This approach forces prioritization of essential content and features.
/* Base styles - mobile (< 640px) */
.container {
padding: 1rem;
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
/* Tablet (≥ 640px) */
@media (min-width: 640px) {
.container {
padding: 1.5rem;
font-size: 16px;
}
.grid {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
}
/* Desktop (≥ 1024px) */
@media (min-width: 1024px) {
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.grid {
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
}
/* Large desktop (≥ 1280px) */
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
Why Min-Width Over Max-Width:
Mobile-first uses min-width queries that build upon base styles. This is simpler than desktop-first approaches that require overriding complex desktop styles for mobile.
/* BAD: Desktop-first (max-width) */
.element {
/* Complex desktop styles */
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 2rem;
}
@media (max-width: 768px) {
.element {
/* Must override everything */
display: block;
flex-direction: unset;
justify-content: unset;
padding: 1rem;
}
}
/* GOOD: Mobile-first (min-width) */
.element {
/* Simple mobile styles */
padding: 1rem;
}
@media (min-width: 768px) {
.element {
/* Progressive enhancement */
display: flex;
justify-content: space-between;
padding: 2rem;
}
}
Breakpoint Strategy
Define consistent breakpoints across the application:
// styles/breakpoints.ts
export const breakpoints = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
} as const;
// Custom hook for responsive behavior
export function useBreakpoint() {
const [breakpoint, setBreakpoint] = React.useState<keyof typeof breakpoints>('sm');
React.useEffect(() => {
const queries = Object.entries(breakpoints).map(([key, value]) => ({
key: key as keyof typeof breakpoints,
query: window.matchMedia(`(min-width: ${value})`),
}));
const updateBreakpoint = () => {
// Find largest matching breakpoint
const matching = queries
.filter(({ query }) => query.matches)
.map(({ key }) => key);
setBreakpoint(matching[matching.length - 1] || 'sm');
};
queries.forEach(({ query }) =>
query.addEventListener('change', updateBreakpoint)
);
updateBreakpoint();
return () => {
queries.forEach(({ query }) =>
query.removeEventListener('change', updateBreakpoint)
);
};
}, []);
return breakpoint;
}
// Usage
export function Dashboard() {
const breakpoint = useBreakpoint();
const isMobile = breakpoint === 'sm';
return (
<div>
{isMobile ? <MobileNav /> : <DesktopNav />}
<Content />
</div>
);
}
Container Queries
Container queries (relatively new) allow styling based on parent container size rather than viewport size. This enables truly modular components that adapt to their container.
.card-container {
container-type: inline-size;
container-name: card;
}
.card {
padding: 1rem;
}
.card-title {
font-size: 1rem;
}
/* When container is ≥ 400px */
@container card (min-width: 400px) {
.card {
padding: 1.5rem;
}
.card-title {
font-size: 1.25rem;
}
}
/* When container is ≥ 600px */
@container card (min-width: 600px) {
.card {
display: flex;
gap: 1.5rem;
}
.card-title {
font-size: 1.5rem;
}
}
// Card adapts to container width, not viewport width
<div className="sidebar">
<Card /> {/* Narrow layout */}
</div>
<div className="main-content">
<Card /> {/* Wide layout */}
</div>
Fluid Typography
Use clamp() for typography that scales smoothly between breakpoints:
/* Font size scales from 14px to 18px between viewport widths 320px to 1280px */
.body {
font-size: clamp(0.875rem, 0.75rem + 0.5vw, 1.125rem);
}
/* Heading scales from 24px to 48px */
.heading {
font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
}
How clamp() works:
font-size: clamp(min, preferred, max);
min: Minimum valuepreferred: Ideal value (typically using viewport units)max: Maximum value
Responsive Images
Serve appropriately sized images for different devices:
// Responsive image with srcset
<img
src="/payment-graph-800.jpg"
srcSet="
/payment-graph-400.jpg 400w,
/payment-graph-800.jpg 800w,
/payment-graph-1200.jpg 1200w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
alt="Payment volume graph showing monthly trends"
loading="lazy"
/>
// Picture element for art direction
<picture>
<source
media="(min-width: 1024px)"
srcSet="/hero-desktop.jpg"
/>
<source
media="(min-width: 640px)"
srcSet="/hero-tablet.jpg"
/>
<img
src="/hero-mobile.jpg"
alt="Banking dashboard hero image"
/>
</picture>
Theming
Theming enables visual customization without changing component code. Modern theming uses CSS custom properties (variables) for runtime theme switching.
CSS Custom Properties
CSS custom properties (CSS variables) provide runtime theming with excellent browser support:
/* Define theme variables */
:root {
/* Colors */
--color-primary: #2196F3;
--color-primary-hover: #1976D2;
--color-secondary: #757575;
--color-danger: #D32F2F;
--color-success: #388E3C;
--color-warning: #F57C00;
/* Text */
--color-text-primary: #212121;
--color-text-secondary: #757575;
--color-text-disabled: #BDBDBD;
/* Background */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #F5F5F5;
--color-bg-elevated: #FFFFFF;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-family-base: system-ui, -apple-system, sans-serif;
--font-family-mono: 'Courier New', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
}
/* Dark theme overrides */
[data-theme="dark"] {
--color-text-primary: #FFFFFF;
--color-text-secondary: #B0B0B0;
--color-bg-primary: #121212;
--color-bg-secondary: #1E1E1E;
--color-bg-elevated: #2D2D2D;
}
/* Component using variables */
.button {
background-color: var(--color-primary);
color: white;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-md);
transition: background-color var(--transition-base);
}
.button:hover {
background-color: var(--color-primary-hover);
}
Theme Switching
Implement runtime theme switching:
// ThemeProvider.tsx
type Theme = 'light' | 'dark' | 'auto';
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = React.useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme;
return stored || 'auto';
});
const resolvedTheme = React.useMemo(() => {
if (theme !== 'auto') return theme;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}, [theme]);
const setTheme = React.useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
}, []);
React.useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}, [resolvedTheme]);
// Listen for system theme changes when in auto mode
React.useEffect(() => {
if (theme !== 'auto') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
document.documentElement.setAttribute(
'data-theme',
mediaQuery.matches ? 'dark' : 'light'
);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
// Theme switcher component
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
return (
<div className="theme-toggle">
<label htmlFor="theme-select">Theme</label>
<select
id="theme-select"
value={theme}
onChange={e => setTheme(e.target.value as Theme)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
<span>Current: {resolvedTheme}</span>
</div>
);
}
Semantic Color Tokens
Use semantic color names that describe purpose, not appearance:
/* BAD: Color names describe appearance */
:root {
--blue-500: #2196F3;
--blue-700: #1976D2;
--gray-700: #616161;
}
.button-primary {
background: var(--blue-500);
}
/* GOOD: Semantic names describe purpose */
:root {
--color-primary: #2196F3;
--color-primary-hover: #1976D2;
--color-text-primary: #212121;
--color-text-secondary: #616161;
--color-surface: #FFFFFF;
--color-background: #F5F5F5;
}
.button-primary {
background: var(--color-primary);
color: var(--color-surface);
}
.button-primary:hover {
background: var(--color-primary-hover);
}
Semantic naming enables theme changes without modifying component styles. Changing --color-primary updates all primary buttons.
Design Systems
Design systems provide reusable components, patterns, and guidelines ensuring consistency across applications.
Component Library Structure
design-system/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.stories.tsx # Storybook stories
│ │ ├── Button.test.tsx
│ │ ├── Button.module.css
│ │ └── index.ts
│ ├── Input/
│ ├── Card/
│ └── Modal/
│
├── tokens/
│ ├── colors.ts
│ ├── spacing.ts
│ ├── typography.ts
│ └── shadows.ts
│
├── hooks/
│ ├── useTheme.ts
│ └── useMediaQuery.ts
│
└── utils/
└── classNames.ts
Design Tokens
Design tokens are design decisions (colors, spacing, typography) represented as data. They ensure consistency and enable theme switching.
// tokens/colors.ts
export const colors = {
// Brand colors
primary: {
50: '#E3F2FD',
100: '#BBDEFB',
200: '#90CAF9',
300: '#64B5F6',
400: '#42A5F5',
500: '#2196F3', // Main brand color
600: '#1E88E5',
700: '#1976D2',
800: '#1565C0',
900: '#0D47A1',
},
// Semantic colors
success: '#388E3C',
warning: '#F57C00',
danger: '#D32F2F',
info: '#1976D2',
// Neutral colors
gray: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#EEEEEE',
300: '#E0E0E0',
400: '#BDBDBD',
500: '#9E9E9E',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121',
},
} as const;
// tokens/spacing.ts
export const spacing = {
xs: '0.25rem', // 4px
sm: '0.5rem', // 8px
md: '1rem', // 16px
lg: '1.5rem', // 24px
xl: '2rem', // 32px
'2xl': '3rem', // 48px
'3xl': '4rem', // 64px
} as const;
// tokens/typography.ts
export const typography = {
fontFamily: {
base: 'system-ui, -apple-system, sans-serif',
mono: 'Menlo, Monaco, Courier New, monospace',
},
fontSize: {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem',// 30px
'4xl': '2.25rem', // 36px
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
lineHeight: {
tight: 1.25,
normal: 1.5,
relaxed: 1.75,
},
} as const;
Using Design Tokens
import { colors, spacing, typography } from '@/design-system/tokens';
// In CSS-in-JS
const Button = styled.button`
padding: ${spacing.md} ${spacing.lg};
background-color: ${colors.primary[500]};
font-size: ${typography.fontSize.base};
font-weight: ${typography.fontWeight.semibold};
border-radius: 8px;
`;
// Generate CSS custom properties
const root = document.documentElement;
root.style.setProperty('--color-primary', colors.primary[500]);
root.style.setProperty('--spacing-md', spacing.md);
Performance Optimization
Critical CSS
Extract and inline critical CSS (above-the-fold styles) to eliminate render-blocking CSS:
<!DOCTYPE html>
<html>
<head>
<!-- Inline critical CSS -->
<style>
/* Critical styles for above-the-fold content */
body { margin: 0; font-family: system-ui; }
.header { background: #2196F3; padding: 1rem; }
/* ... */
</style>
<!-- Load remaining CSS asynchronously -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>
<body>
<!-- Content -->
</body>
</html>
Tools like Critical or Critters automatically extract critical CSS during builds.
CSS Purging
Remove unused CSS from production bundles. Most frameworks provide purging:
// Tailwind CSS - automatic purging
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
],
// Tailwind automatically removes unused utilities
};
// PurgeCSS - manual configuration
module.exports = {
content: ['./src/**/*.html', './src/**/*.tsx'],
css: ['./src/**/*.css'],
safelist: [
'modal-open', // Classes added dynamically
/^toast-/, // Patterns
],
};
CSS Containment
Use CSS containment to isolate subtrees, improving rendering performance:
.card {
/* Tells browser this element's content won't affect outside layout */
contain: layout style paint;
}
.independent-widget {
/* Full containment */
contain: strict;
}
Related Documentation
- Frontend Overview - Development principles
- Frontend Components - Component architecture
- React Performance - React-specific optimizations
- Angular Performance - Angular optimizations
External Resources
- CSS Tricks
- Modern CSS Solutions
- Tailwind CSS Documentation
- styled-components Documentation
- CSS Container Queries
Summary
Modern CSS approaches range from scoped CSS Modules to utility-first Tailwind to dynamic CSS-in-JS. Each offers distinct tradeoffs in maintainability, performance, and developer experience. Responsive design uses mobile-first strategies with fluid typography and container queries. Theming with CSS custom properties enables runtime theme switching. Design systems ensure consistency through shared components and design tokens. Performance optimization includes critical CSS extraction, purging unused styles, and CSS containment. Choose approaches that match team expertise and project requirements.