Responsive Components Without ResizeObserver Code
Viewport breakpoints are not enough: components need to react to their own size, not to the window width. x-resize brings the ResizeObserver into Alpine.js declaratively, without a single line of boilerplate code.
Table of Contents
- 1. The Problem With window.resize and Viewport Breakpoints
- 2. The Native ResizeObserver and Its Boilerplate
- 3. The x-resize Plugin: Installation and Basic Principle
- 4. x-resize Basics: Reading width and height
- 5. Component Breakpoints Without CSS Container Queries
- 6. Responsive Tables With x-resize
- 7. Sidebar Layout: Breakpoints at the Component Level
- 8. Performance: Using ResizeObserver Correctly
- 9. x-resize vs. CSS Container Queries vs. window.resize
- 10. Summary
- 11. FAQ
1. The Problem With window.resize and Viewport Breakpoints
Responsive design is traditionally implemented through viewport breakpoints: when the browser window is narrower than 768 pixels, the layout switches. That works well for page structure and global layouts, but it fails for reusable components. A product card that shows four products side by side in the main column needs to show two in a narrower sidebar, even though the viewport is identical. The viewport tells the component nothing about its own available width.
The classic workaround was window.addEventListener('resize', handler). This approach has several problems: it reacts to window size changes, not element size changes. A component that grows or shrinks because of a sidebar toggle, an accordion, or a dynamic layout does not trigger a resize event. The handler has to query the element's width itself via getBoundingClientRect(), which forces a layout reflow. The result is fragile, hard to test code that requires special cases for every possible layout context.
The native ResizeObserver solves exactly this problem: it observes size changes of individual elements, regardless of the cause. But using it directly requires boilerplate: instantiate the observer, register it, observe during cleanup, and disconnect it when the component is destroyed. x-resize encapsulates this logic entirely in a single Alpine.js directive.
2. The Native ResizeObserver and Its Boilerplate
To understand what x-resize does under the hood, it helps to look at the native ResizeObserver. The browser API is conceptually simple: you create an observer with a callback, call observer.observe(element), and the callback fires whenever the element's size changes. The callback receives an array of ResizeObserverEntry objects with a contentRect that holds the new size.
The boilerplate effort in Alpine.js without x-resize is considerable: create and start the observer in x-init, react to data in x-effect or via $refs, and disconnect the observer again in the cleanup hook ($destroy or a lifecycle hook). If you forget to disconnect it, you get a memory leak, because the observer holds a reference to the element and it cannot be released by the garbage collector. x-resize handles this entire lifecycle fully automatically.
// WITHOUT x-resize: manual ResizeObserver boilerplate in Alpine.js
<div x-data="{
width: 0,
height: 0,
observer: null,
init() {
this.observer = new ResizeObserver(([entry]) => {
this.width = Math.round(entry.contentRect.width)
this.height = Math.round(entry.contentRect.height)
})
this.observer.observe(this.$el)
},
destroy() {
// Must disconnect manually, or you get a memory leak!
if (this.observer) this.observer.disconnect()
}
}">
<p x-text="`Width: ${width}px, Height: ${height}px`"></p>
</div>
// WITH x-resize: zero boilerplate, lifecycle handled automatically
<div
x-data="{ width: 0, height: 0 }"
x-resize="width = $width; height = $height"
>
<p x-text="`Width: ${width}px, Height: ${height}px`"></p>
</div>
3. The x-resize Plugin: Installation and Basic Principle
The x-resize plugin, like x-mask, is an official Alpine.js plugin and is installed the same way. It must be registered with Alpine.plugin(resize) before Alpine.start(). In Hyvä projects, the recommended approach is an npm install and inclusion in the central Tailwind build entrypoint. The plugin is minimal and does not add any meaningful bundle size.
The basic principle of x-resize is simple: the directive is placed on an element and given a JavaScript expression as its value. That expression runs every time the element's size changes. Two magic variables are available inside the expression: $width and $height, which hold the element's current width and height in pixels. These values can be written directly into Alpine data properties or used to compute breakpoints.
4. x-resize Basics: Reading width and height
The simplest use of x-resize is observing the current element size and storing it in Alpine data. x-resize="width = $width; height = $height" on a container element ensures that width and height in the Alpine data always match the current values. These values are reactive: any x-text, x-show, or x-bind that depends on them updates automatically.
One important detail: x-resize fires once initially when the component is initialized. That means the size is known right from the start, not only after the first manual resize. For components that need to render their layout correctly from the very first paint (not only after the first window resize), that is essential. The callback is also debounced, so that rapid, continuous size changes (for example when dragging the window edge) do not trigger it on every single frame.
// Basic x-resize usage: track element size reactively
<div
x-data="{
containerWidth: 0,
get breakpoint() {
if (this.containerWidth < 400) return 'xs'
if (this.containerWidth < 640) return 'sm'
if (this.containerWidth < 768) return 'md'
return 'lg'
},
get columns() {
return { xs: 1, sm: 2, md: 3, lg: 4 }[this.breakpoint]
}
}"
x-resize="containerWidth = $width"
class="w-full"
>
<!-- Layout adapts to container width, not viewport width -->
<p class="text-xs text-slate-500 mb-4">
Container: <span x-text="containerWidth + 'px'"></span>
· Breakpoint: <span x-text="breakpoint"></span>
· Spalten: <span x-text="columns"></span>
</p>
<div :class="`grid gap-4 grid-cols-${columns}`">
<template x-for="i in 8" :key="i">
<div class="bg-teal-100 rounded p-4 text-center text-sm font-medium"
x-text="`Produkt ${i}`"></div>
</template>
</div>
</div>
5. Component Breakpoints Without CSS Container Queries
CSS Container Queries are a modern browser feature that enables breakpoints at the container level, similar to what x-resize does for JavaScript logic. Both approaches are complementary, not competing. CSS Container Queries control pure CSS adjustments such as font sizes, spacing, and colors, while x-resize controls JavaScript logic and DOM structure: which component is displayed, how many columns a grid has, whether an accordion or tabs are used.
For teams that still need to target older browsers, or that need more complex JavaScript logic based on the container size, x-resize is the more reliable solution. The breakpoints are defined as computed getters in Alpine data, which makes them fully testable and inspectable. A getter such as get isWide() { return this.containerWidth >= 640 } is clearer than a CSS selector and can be reused in more complex decision trees.
6. Responsive Tables With x-resize
Tables are one of the hardest elements to handle in responsive design. A data table with eight columns cannot be rendered correctly at 320 pixels: horizontal scrolling is unsatisfying, and truncated text becomes unreadable. The standard pattern in modern web applications is converting the table into a card list on small screens, where each row becomes a card with the column label and value stacked underneath each other.
With x-resize, this switch can be controlled directly at the container level. When the width of the table container drops below a threshold, the Alpine component switches from table layout to card layout. That is more precise than a viewport breakpoint, because the table reacts correctly whether it is rendered in a wide main column or a narrow sidebar. The template contains both layouts, and x-show controls which one is active.
<!-- Responsive table: switches to card layout when container is narrow -->
<div
x-data="{
containerWidth: 0,
get useCardLayout() { return this.containerWidth < 560 },
orders: [
{ id: '2026-001', date: '10.05.2026', product: 'Alpine.js Kurs', amount: '49,00 €', status: 'Bezahlt' },
{ id: '2026-002', date: '09.05.2026', product: 'Hyvä Lizenz', amount: '199,00 €', status: 'Offen' },
{ id: '2026-003', date: '08.05.2026', product: 'DevOps Paket', amount: '349,00 €', status: 'Bezahlt' }
]
}"
x-resize="containerWidth = $width"
>
<!-- Table layout (wide containers) -->
<table x-show="!useCardLayout" class="w-full text-sm border-collapse">
<thead class="bg-slate-100">
<tr>
<th class="text-left p-3 font-semibold">Bestell-Nr.</th>
<th class="text-left p-3 font-semibold">Datum</th>
<th class="text-left p-3 font-semibold">Produkt</th>
<th class="text-left p-3 font-semibold">Betrag</th>
<th class="text-left p-3 font-semibold">Status</th>
</tr>
</thead>
<tbody>
<template x-for="o in orders" :key="o.id">
<tr class="border-t border-slate-200">
<td class="p-3" x-text="o.id"></td>
<td class="p-3" x-text="o.date"></td>
<td class="p-3" x-text="o.product"></td>
<td class="p-3 font-semibold" x-text="o.amount"></td>
<td class="p-3"><span class="px-2 py-1 rounded text-xs font-bold bg-teal-100 text-teal-700" x-text="o.status"></span></td>
</tr>
</template>
</tbody>
</table>
<!-- Card layout (narrow containers) -->
<div x-show="useCardLayout" class="space-y-3">
<template x-for="o in orders" :key="o.id">
<div class="border border-slate-200 rounded-xl p-4 text-sm">
<div class="flex justify-between mb-2">
<span class="font-semibold" x-text="o.id"></span>
<span class="px-2 py-1 rounded text-xs font-bold bg-teal-100 text-teal-700" x-text="o.status"></span>
</div>
<p class="text-slate-600" x-text="o.product"></p>
<div class="flex justify-between mt-2 text-xs text-slate-500">
<span x-text="o.date"></span>
<span class="font-semibold text-slate-800" x-text="o.amount"></span>
</div>
</div>
</template>
</div>
</div>
7. Sidebar Layout: Breakpoints at the Component Level
A typical Hyvä layout has a main area and an optional sidebar. When the sidebar is shown, the main area becomes narrower, and a product list that previously had four columns needs to switch to three or two columns. A viewport breakpoint has no idea whether the sidebar is open or closed. x-resize knows, because it observes the current width of the container in real time.
The pattern is simple: the product list component has x-resize tracking the container width. A getter computes the number of columns. When the user shows or hides the sidebar via a button, the width of the product list container changes, x-resize fires, the column count is recalculated, and the grid adapts automatically, without the product list ever needing to know that a sidebar exists. That is true component encapsulation.
8. Performance: Using ResizeObserver Correctly
ResizeObserver callbacks fire synchronously before the browser's next paint. That is powerful, but it can cause performance problems if the callback itself causes DOM changes that trigger further size changes, a so called resize loop. Alpine.js and x-resize do not prevent this automatically. Developers need to make sure that the x-resize expression does not perform direct DOM manipulations that alter the observed element's size.
In practice, the most common case is unproblematic: x-resize only writes to Alpine data properties, and Alpine updates the DOM asynchronously through its own reactivity mechanism. That does not lead to synchronous resize loops. It becomes problematic if you set $el.style.height directly inside the x-resize expression, which can immediately trigger another resize event. The correct approach: write the size into data, and control layout through :class or :style bindings.
9. x-resize vs. CSS Container Queries vs. window.resize
All three approaches solve the problem of component dependent responsiveness, but at different levels and with different strengths. CSS Container Queries are a pure CSS solution, need no JavaScript, and are performant, they are suitable for any layout adjustment that can be expressed in CSS. x-resize is the JavaScript extension: it controls logical decisions, DOM structure, and Alpine state. window.resize is the approach of the past, which no longer has a place in modern Alpine.js projects.
| Approach | Reacts To | JavaScript Needed? | Strength |
|---|---|---|---|
| window.resize | Window width | Yes (lots of boilerplate) | Outdated, imprecise |
| CSS Container Queries | Container width | No | Pure CSS solution, performant |
| x-resize (Alpine) | Element size | Alpine.js (declarative) | JS logic, DOM structure, no boilerplate |
| Native ResizeObserver | Element size | Yes (lots of boilerplate) | Maximum control, high effort |
The recommendation for Hyvä projects is a combined approach: CSS Container Queries for layout adjustments that can be expressed purely in CSS, and x-resize for anything involving Alpine.js state or DOM structure. Both can be active on the same element at the same time, they do not interfere with each other. window.resize no longer has a place in new components.
Mironsoft
Alpine.js · Hyvä Themes · Magento 2 Frontend Development
Components that work in every layout?
We build Hyvä components that respond to their own width, for product galleries, data tables, and filter bars that render correctly in every sidebar and every main area.
Container Awareness
Components that respond to their own width, not to the viewport
Responsive Tables
Automatic switching between table view and card layout depending on available width
Layout Refactoring
Replace window.resize listeners with x-resize: more maintainable, more correct, no boilerplate
10. Summary
Alpine.js x-resize is the declarative answer to one of the hardest challenges in responsive design: components that need to react to their own size, not to the viewport. The plugin fully encapsulates the native ResizeObserver, including lifecycle management, the initial call, and disconnecting on destroy. The result is components that react correctly in every sidebar configuration, every grid context, and every layout change, without window.resize handlers or manual observer management.
The most important difference from CSS Container Queries: x-resize controls JavaScript logic and DOM structure, not CSS properties. For breakpoints that only involve visual adjustments (font size, spacing, colors), Container Queries are the right choice. For breakpoints that change Alpine state, show and hide DOM elements, or calculate column counts, x-resize is indispensable. In modern Hyvä projects, both work together.
Alpine.js x-resize: The Essentials at a Glance
Magic Variables
$width and $height hold the current element size in pixels. Available inside the x-resize expression, which runs on every size change.
Automatic Lifecycle
x-resize starts on initialization, fires once initially, and disconnects the observer automatically when the component is destroyed. No manual cleanup needed.
No Resize Loops
Only change Alpine data inside the x-resize expression. No direct DOM manipulations that alter the element size, otherwise a resize loop can occur.
Combining With CSS
CSS Container Queries for visual adjustments, x-resize for JavaScript logic. Both can be active on the same element without interference.