CSS Responsive Design and Modern Features


  • Description: Media queries, fluid typography, custom properties for theming, dark mode, transitions, animations, and modern CSS shipped 2022-2026.
  • My Notion Note ID: K2A-F2-3
  • Created: 2018-03-23
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Media Queries

Apply rules conditionally based on viewport, device, or user preference.

@media (min-width: 768px) {
  .nav { display: flex; }
}

@media (max-width: 767px) and (orientation: portrait) {
  .nav { display: none; }
}

1.1 Common Features

Feature Examples
width, height (min-width: 768px), (max-width: 1024px)
orientation portrait, landscape
prefers-color-scheme light, dark
prefers-reduced-motion reduce, no-preference
prefers-contrast more, less, no-preference
prefers-reduced-transparency
forced-colors active, none (Windows high-contrast mode)
pointer, hover coarse/fine, hover/none — touch vs mouse
resolution (min-resolution: 2dppx) (retina)

1.2 Modern Range Syntax

Cleaner than min-width/max-width:

@media (768px <= width < 1280px) { ... }
@media (width >= 768px)          { ... }

1.3 Breakpoints

There are no canonical breakpoints. Common starting points:

Tier Width
Mobile < 640px
Tablet 640-1024px
Desktop 1024-1280px
Wide > 1280px

Tailwind's defaults (sm, md, lg, xl, 2xl) map to 640 / 768 / 1024 / 1280 / 1536px. Pick the breakpoints your content needs, not an arbitrary list.

Mobile-first is the dominant convention — base styles target small screens, @media (min-width: ...) adds desktop refinements. Simpler CSS, smaller mobile payload.

2. Fluid Typography and Spacing

Discrete breakpoints cause jumps in font size at boundaries. Modern approach: a single fluid expression with clamp().

:root {
  /* Scales from 1rem at 320px viewport to 1.25rem at 1280px */
  --step-0: clamp(1rem, 0.83rem + 0.83vw, 1.25rem);

  /* Body */
  --step-1: clamp(1.125rem, 0.93rem + 0.93vw, 1.5rem);

  /* Heading 1 */
  --step-5: clamp(2rem, 1.43rem + 2.86vw, 4rem);
}

body { font-size: var(--step-1); }
h1   { font-size: var(--step-5); }

Tools: https://utopia.fyi/ generates the full clamp scale from min/max values and breakpoints.

Same trick for spacing:

--space-md: clamp(1rem, 0.5rem + 2vw, 2rem);

Pitfall: clamp() arguments must be exact. The middle value is the fluid value — usually (intercept) + (slope * 100vw). The min/max clamp it.

3. Custom Properties and Theming

Custom properties (CSS variables) cascade and inherit, so theming becomes setting a few root-level tokens.

:root {
  --color-bg: oklch(99% 0 0);
  --color-fg: oklch(20% 0 0);
  --color-muted: oklch(55% 0 0);
  --color-accent: oklch(60% 0.18 250);
  --radius: 0.5rem;
}

.button {
  background: var(--color-accent);
  color: var(--color-bg);
  border-radius: var(--radius);
}

3.1 Scoped Theming

Override at any level:

.card {
  --color-bg: oklch(96% 0 0);   /* only inside .card */
}

.card .button { background: var(--color-bg); }

3.2 Reading and Writing from JS

// Read computed value
getComputedStyle(document.documentElement).getPropertyValue('--color-accent');

// Write
document.documentElement.style.setProperty('--color-accent', 'oklch(70% 0.2 30)');

3.3 Typed Custom Properties

@property registers a custom property with a type — enables animation interpolation:

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.box {
  background: linear-gradient(var(--gradient-angle), red, blue);
  animation: spin 4s linear infinite;
}

@keyframes spin {
  to { --gradient-angle: 360deg; }
}

Without @property, animating a custom property would jump rather than interpolate.

4. Dark Mode

Two ways to wire it up.

4.1 OS Preference Only

:root {
  --color-bg: white;
  --color-fg: black;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: black;
    --color-fg: white;
  }
}

Simple, fully declarative, respects user setting.

4.2 Manual Toggle With OS Fallback

Add a data-theme attribute or class controlled by JS, persist preference in localStorage:

:root { color-scheme: light; --color-bg: white; --color-fg: black; }

:root[data-theme="dark"] { color-scheme: dark; --color-bg: black; --color-fg: white; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) { color-scheme: dark; --color-bg: black; --color-fg: white; }
}
<script>
  // Apply theme before render to avoid flash of wrong theme
  const saved = localStorage.getItem('theme');
  if (saved) document.documentElement.dataset.theme = saved;
</script>

color-scheme is the magic line — it tells the UA to use dark-mode variants for form controls, scrollbars, and the default text/background.

4.3 light-dark() (2024+)

One declaration, two values:

:root { color-scheme: light dark; }
body  { background: light-dark(white, black); color: light-dark(black, white); }

Browser picks based on color-scheme. Cleaner than maintaining two variable sets.

5. Transitions

Animate property changes between two states.

.button {
  background: oklch(60% 0.18 250);
  transition: background 200ms ease-out, transform 150ms ease-out;
}
.button:hover {
  background: oklch(70% 0.18 250);
  transform: translateY(-1px);
}

Shorthand: transition: PROPERTY DURATION TIMING-FUNCTION DELAY. Comma-separate multiple transitions.

Timing function Curve
linear Constant velocity.
ease (default) Slow start, fast middle, slow end.
ease-in Slow start.
ease-out Slow end. Best for entering/expanding things.
ease-in-out Slow at both ends.
cubic-bezier(.4, 0, .2, 1) Custom — Material's default.
linear(...) Multi-stop curves (2023+) — bouncing, spring-like motion without keyframes.

5.1 Not All Properties Transition

Properties must have intermediate values. display: noneblock historically didn't transition. As of 2024 with transition-behavior: allow-discrete:

.modal {
  display: none;
  opacity: 0;
  transition: opacity 200ms, display 200ms allow-discrete;
  transition-behavior: allow-discrete;
}
.modal.open { display: block; opacity: 1; }

@starting-style defines the starting values when an element first appears:

@starting-style {
  .modal.open { opacity: 0; }
}

5.2 Respect Reduced Motion

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

6. Animations

Reusable, multi-stage motion via @keyframes.

@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

.toast {
  animation: fade-in-up 300ms ease-out;
}

Properties:

Property Meaning
animation-name Keyframe name.
animation-duration How long.
animation-timing-function Same vocabulary as transitions.
animation-delay Wait before starting. Negative = start partway through.
animation-iteration-count Number of times or infinite.
animation-direction normal, reverse, alternate, alternate-reverse.
animation-fill-mode none, forwards (keep final state), backwards (apply initial state during delay), both.
animation-play-state running, paused.

Shorthand: animation: NAME DURATION TIMING DELAY ITER DIR FILL PLAY.

6.1 Scroll-Driven Animations (2024+)

Drive an animation by scroll progress instead of time:

@keyframes reveal {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.card {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

Solid in Chromium; partial in Firefox/Safari as of 2026 — feature-detect with @supports (animation-timeline: scroll()).

6.2 View Transitions API

CSS hook for cross-page or cross-state animations:

document.startViewTransition(() => updateDOM());
::view-transition-old(root) { animation: fade-out 200ms; }
::view-transition-new(root) { animation: fade-in 200ms; }

7. Transforms and Filters

7.1 Transforms

.box {
  transform: translate(10px, 20px) rotate(15deg) scale(1.1);
  transform-origin: center;     /* pivot point */
}

/* Individual properties (cleaner, animatable independently) */
.box {
  translate: 10px 20px;
  rotate: 15deg;
  scale: 1.1;
}

3D transforms — add transform-style: preserve-3d on parent, then rotateX, rotateY, translateZ, perspective(...).

7.2 Filters

img { filter: blur(2px) brightness(0.9) saturate(1.1); }
img:hover { filter: none; }
Function Effect
blur(LEN) Gaussian blur.
brightness(N) 0 = black, 1 = unchanged.
contrast(N) 0 = grey, 1 = unchanged.
grayscale(0-1) Desaturate.
sepia(0-1) Brownish tint.
invert(0-1) Colour invert.
hue-rotate(deg) Shift hue.
drop-shadow(...) Like box-shadow but follows alpha (great for icons).
backdrop-filter Apply filter to what's behind the element — frosted glass.

8. Cascade Layers, Nesting, Scope

8.1 Cascade Layers

Predictable overrides across many sources. Layers later in declaration win, regardless of selector specificity.

@layer reset, base, components, utilities;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
}

@layer base {
  body { font-family: system-ui; }
}

@layer components {
  .button { padding: 0.5em 1em; }
}

@layer utilities {
  .hidden { display: none; }    /* always wins over .button */
}

Unlayered styles win against any layered style by default. Great for incremental adoption — drop a vendor stylesheet into @layer vendor and it sits below your application styles.

8.2 Nesting

Native nesting (no Sass needed) — browser-supported since 2023:

.card {
  padding: 1rem;
  border-radius: 0.5rem;

  & .title {
    font-weight: bold;
  }

  &:hover {
    background: oklch(96% 0 0);
  }

  @media (min-width: 768px) {
    padding: 1.5rem;
  }
}

& references the outer selector. Differences from Sass:

  • & is mandatory in front of an element selector (& span, not bare span) in the original spec; relaxed in 2023.
  • Doesn't flatten — the rule remains nested at compute time.

8.3 Scope (2024+)

Limit a rule's reach to a subtree:

@scope (.card) to (.card-footer) {
  p { color: var(--color-muted); }
}

The to clause is the scope ceiling — descendants stop matching past it. Useful for component CSS that shouldn't leak into nested components of the same kind.

9. References