ResizeObserver and Container Queries: Element-Level Responsive Layout Solutions
From Viewport Responsive to Element Responsive
Traditional responsiveness is based on viewport width (@media), but components should adapt to their container size:
| Aspect | @media Viewport Query | Container Query | ResizeObserver |
|---|---|---|---|
| Responds to | Viewport width | Container width | Element size |
| Scope | Global | Within container | JS callback |
| CSS-driven | ✅ | ✅ | ❌ |
| JS-driven | ❌ | ❌ | ✅ |
| Component portability | Poor | Good | Good |
| Performance | Browser-optimized | Browser-optimized | Watch callback frequency |
ResizeObserver API Deep Dive
Basic Usage
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`${entry.target.className}: ${width}×${height}`);
const borderBox = entry.borderBoxSize?.[0];
console.log(`With border: ${borderBox?.inlineSize}×${borderBox?.blockSize}`);
}
});
observer.observe(document.querySelector('.card'));
Observation Options
observer.observe(element, {
box: 'content-box' // Default, content area
// box: 'border-box' // Including padding + border
// box: 'device-pixel-content-box' // Device pixel level
});
Practice: Adaptive Card Layout
class ResponsiveCard {
private observer: ResizeObserver;
constructor(private card: HTMLElement) {
this.observer = new ResizeObserver((entries) => {
const { width } = entries[0].contentRect;
this.applyLayout(width);
});
this.observer.observe(card);
}
private applyLayout(width: number) {
if (width < 200) {
this.card.setAttribute('data-layout', 'compact');
} else if (width < 400) {
this.card.setAttribute('data-layout', 'normal');
} else {
this.card.setAttribute('data-layout', 'expanded');
}
}
destroy() {
this.observer.disconnect();
}
}
.card[data-layout="compact"] {
flex-direction: column;
font-size: 12px;
}
.card[data-layout="normal"] {
flex-direction: column;
font-size: 14px;
}
.card[data-layout="expanded"] {
flex-direction: row;
font-size: 16px;
}
Practice: Responsive Chart
class ResponsiveChart {
private observer: ResizeObserver;
private chart: Chart | null = null;
constructor(private container: HTMLElement) {
this.observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
this.resizeChart(width, height);
});
this.observer.observe(container);
}
private resizeChart(width: number, height: number) {
if (this.chart) {
this.chart.resize({ width, height });
} else {
this.chart = new Chart(this.container, {
type: 'line',
data: this.getChartData(width),
options: {
responsive: false,
maintainAspectRatio: false,
}
});
}
}
private getChartData(width: number) {
const pointCount = width < 300 ? 5 : width < 600 ? 10 : 20;
return { labels: generateLabels(pointCount), datasets: [...] };
}
destroy() {
this.observer.disconnect();
this.chart?.destroy();
}
}
Practice: Text Truncation Detection
class TextOverflowDetector {
private observer: ResizeObserver;
constructor(
private element: HTMLElement,
private onOverflow: (isOverflow: boolean) => void
) {
this.observer = new ResizeObserver(() => {
const isOverflow = this.checkOverflow();
this.onOverflow(isOverflow);
});
this.observer.observe(element);
}
private checkOverflow(): boolean {
return this.element.scrollWidth > this.element.clientWidth;
}
destroy() {
this.observer.disconnect();
}
}
const detector = new TextOverflowDetector(
titleElement,
(overflow) => {
tooltipElement.style.display = overflow ? 'inline' : 'none';
}
);
CSS Container Queries
Basic Syntax
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
flex-direction: row;
gap: 16px;
}
.card-title {
font-size: 20px;
}
}
@container card (min-width: 200px) and (max-width: 399px) {
.card {
flex-direction: column;
}
.card-title {
font-size: 16px;
}
}
@container card (max-width: 199px) {
.card-title {
font-size: 14px;
}
.card-meta {
display: none;
}
}
Container Query Units
.card-container {
container-type: inline-size;
}
.card-title {
font-size: clamp(14px, 5cqi, 24px);
padding: 2cqi;
}
| Unit | Meaning | Full name |
|---|---|---|
cqw |
1% of container width | container-query-width |
cqh |
1% of container height | container-query-height |
cqi |
1% of container inline size | container-query-inline |
cqb |
1% of container block size | container-query-block |
cqmin |
min(cqi, cqb) | Smaller dimension |
cqmax |
max(cqi, cqb) | Larger dimension |
Container Queries vs Media Queries
Same Component, Different Contexts
<div class="sidebar">
<product-card /> <!-- Sidebar: narrow container → compact layout -->
</div>
<div class="main-content">
<product-card /> <!-- Main: wide container → expanded layout -->
</div>
.product-card-container {
container-type: inline-size;
}
@container (min-width: 350px) {
.product-card { flex-direction: row; }
}
@container (max-width: 349px) {
.product-card { flex-direction: column; }
}
Media queries can't do this: same component auto-adapting in different positions.
Comparison Summary
| Scenario | @media | @container |
|---|---|---|
| Global layout (nav/footer) | ✅ Appropriate | ❌ Not needed |
| Component adaptation | ❌ Manual breakpoints | ✅ Auto-adapts |
| Component reusability | Poor (viewport-dependent) | Good (container-dependent) |
| Nested components | ❌ Can't sense parent | ✅ Adapts per level |
| Performance | Viewport change triggers | Container change triggers |
ResizeObserver + Container Query Combined
class SmartResponsive {
private observer: ResizeObserver;
constructor(private container: HTMLElement) {
this.observer = new ResizeObserver((entries) => {
const { width } = entries[0].contentRect;
if (width < 300) {
this.showSummary();
} else {
this.showFullData();
}
});
this.observer.observe(container);
}
private showSummary() {
this.container.setAttribute('data-density', 'sparse');
}
private showFullData() {
this.container.setAttribute('data-density', 'dense');
}
destroy() {
this.observer.disconnect();
}
}
Performance Optimization
Debounced ResizeObserver
function debouncedResizeObserver(
callback: (entries: ResizeObserverEntry[]) => void,
delay = 100
): ResizeObserver {
let timer: number;
let latestEntries: ResizeObserverEntry[];
return new ResizeObserver((entries) => {
latestEntries = entries;
clearTimeout(timer);
timer = window.setTimeout(() => callback(latestEntries), delay);
});
}
Batch Processing
const observer = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
for (const entry of entries) {
updateLayout(entry.target as HTMLElement, entry.contentRect);
}
});
});
Browser Compatibility
| API | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| ResizeObserver | 64+ | 69+ | 13.1+ | 79+ |
| Container Queries | 105+ | 110+ | 16+ | 105+ |
| Container Units (cqw/cqi) | 105+ | 110+ | 16+ | 105+ |
container-type |
105+ | 110+ | 16+ | 105+ |
borderBoxSize |
84+ | 69+ | 13.1+ | 84+ |
Best Practices
- Prefer Container Query: Use CSS container queries for pure layout adaptation—no JS needed
- ResizeObserver for logic: Use JS when data/behavior changes are needed
- Disconnect promptly: Unobserve on component unmount to prevent leaks
- Avoid loops: Modifying size in callbacks can trigger new callbacks—debounce
- container-type choice: Use
inline-sizefor width-only adaptation—better perf thansize
Summary
Element-level responsiveness is a core requirement of modern component design. CSS Container Queries enable pure-CSS container adaptation, ResizeObserver provides precise JS size awareness, and together they form a complete element-level responsive solution. Say goodbye to "hardcoded viewport breakpoints"—components truly adapt wherever they're placed.
Use Flexbox Generator for in-container layouts, Gradient Generator for responsive backgrounds, and Box Shadow Generator to debug card styles within containers.
Try these browser-local tools — no sign-up required →