Responsive Vue Component Patterns: Solving Breakpoints Cleanly
AI generated
<v/>
{ }
Vue.js · Responsive Design · Container Queries · Mobile First
Responsive Vue component patterns
from rigid media queries to adaptive components

Responsive Vue components often fail with plain CSS media queries, because a component needs to behave differently depending on its layout context, regardless of viewport width. Container queries, custom breakpoint composables, and adaptive slot structures solve this structurally instead of piling on more CSS exceptions.

17 min read Vue 3 · VueUse · Container Queries · Tailwind Composables · Adaptive Slots

1. Why plain media queries are not enough for components

Responsive Vue components are often implemented exclusively with CSS media queries that react to viewport width. That works well for page level layouts, but fails once a component gets reused across different contexts, for example a card that once sits in a narrow sidebar and once in a wide main area. A media query only knows the viewport width, not the actual available width of the component itself.

The core problem with responsive Vue components is therefore a mix up between two different questions: how big is the screen, and how much space does this one component actually have available right now. For global layout decisions such as showing or hiding a sidebar, the first question is the right one. For the behavior of a reusable component placed differently across grids, sidebars, or modals, the second question is the one that actually matters.

Vue components are by nature reusable building blocks that end up in different layout contexts. That is exactly why it pays off to combine several tools for responsive Vue components: JavaScript based breakpoint composables for structural decisions that go beyond plain CSS, and container queries for components that need to react to their own available space regardless of the viewport.

2. A breakpoint composable as a single source of truth

As soon as a component needs to change not just its appearance but its structure, for example a navigation that is built completely differently on mobile than on desktop, CSS alone is no longer enough. A central breakpoint composable provides reactive boolean values that every component in the project can access, without duplicating breakpoint logic in multiple places.

The VueUse library already provides a mature foundation with useBreakpoints, which can be wrapped in a central composable so that every component uses the same breakpoint definitions as the Tailwind configuration schema. This consistency between CSS breakpoints and JavaScript breakpoints is crucial, because diverging values lead to layout jumps where CSS and JavaScript make different decisions.


// composables/useAppBreakpoints.js
import { useBreakpoints } from '@vueuse/core'

// Mirror Tailwind's default breakpoint scale for consistency
const breakpoints = useBreakpoints({
  sm: 640,
  md: 768,
  lg: 1024,
  xl: 1280,
})

export function useAppBreakpoints() {
  const isMobile = breakpoints.smaller('md')
  const isTablet = breakpoints.between('md', 'lg')
  const isDesktop = breakpoints.greaterOrEqual('lg')
  const active = breakpoints.active()

  return { isMobile, isTablet, isDesktop, active }
}

// components/NavigationRoot.vue — script setup block
import { useAppBreakpoints } from '@/composables/useAppBreakpoints'
import MobileNav from './MobileNav.vue'
import DesktopNav from './DesktopNav.vue'

const { isMobile } = useAppBreakpoints()

// template:
// <MobileNav v-if="isMobile" />
// <DesktopNav v-else />

The advantage of this approach over CSS hiding with hidden md:block: for responsive Vue components with structurally different markup, for example a completely different navigation logic for mobile, both variants are not rendered into the DOM at the same time. That saves unnecessary DOM nodes, prevents duplicate event listeners, and makes it clear to screen readers which version is actually active, instead of hiding both variants via aria-hidden.

3. Container queries: layout depending on the parent element

Container queries solve exactly the problem that plain viewport media queries cannot solve: a component reacts to the width of its own container, regardless of how wide the entire screen is. For responsive Vue components that get reused across different contexts, container queries are therefore often the more fitting solution than global breakpoints.

Technically, an element is declared a query container with container-type: inline-size, and child elements can then react to its width using @container rules, not the viewport's. In Tailwind CSS 4, this functionality is directly usable through the @container class and prefixes such as @sm: or @lg:, without any additional plugin.


<!-- components/StatCard.vue -->
<template>
  <!-- The card itself becomes the query container -->
  <div class="@container rounded-xl border border-slate-200 p-4">
    <!-- Layout adapts to the CARD's own width, not the viewport -->
    <div class="flex flex-col @sm:flex-row @sm:items-center gap-3">
      <div class="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
        <slot name="icon" />
      </div>
      <div>
        <p class="text-xs text-slate-500 @sm:text-sm">{{ label }}</p>
        <p class="text-xl @sm:text-2xl font-bold text-slate-900">{{ value }}</p>
      </div>
    </div>
  </div>
</template>

The very same StatCard component stacks icon and text vertically when embedded in a narrow 200 pixel sidebar, and shows them side by side when placed in a wide dashboard grid column of 400 pixels, without the component itself ever knowing which context it is running in. That is the decisive advantage of container queries over media queries for responsive Vue components: genuine, context independent reusability.

4. Adaptive slots: different content per breakpoint

Some responsive Vue components need to adapt not just their layout but their actual content, for example a shortened description on mobile versus a detailed version on desktop. Scoped slots combined with a breakpoint composable let the calling component provide different content for different screen sizes, without overloading the base component itself with conditional logic.

This pattern works particularly well for generic layout components such as cards or list items, which need to be filled with different levels of detail from many different places in the project. The base component merely exposes the current breakpoint state via a scoped slot, while the decision about the concrete content stays with the caller.


<!-- components/ResponsiveSlot.vue — exposes breakpoint state to its slot -->
<script setup>
import { useAppBreakpoints } from '@/composables/useAppBreakpoints'

const { isMobile, isTablet, isDesktop } = useAppBreakpoints()
</script>

<template>
  <slot :isMobile="isMobile" :isTablet="isTablet" :isDesktop="isDesktop" />
</template>

<!-- usage in a parent component -->
<ResponsiveSlot v-slot="{ isMobile }">
  <p v-if="isMobile">{{ product.shortDescription }}</p>
  <p v-else>{{ product.fullDescription }}</p>
</ResponsiveSlot>

Such a wrapper pattern keeps the actual breakpoint logic in a single place, while any number of places in the project benefit from it without importing useAppBreakpoints themselves each time. For very simple cases, directly importing the composable is perfectly sufficient, while for widely reused, generic base components the scoped slot approach pays off because it clearly leaves responsibility for the concrete content with the caller.

5. Conditional rendering instead of pure CSS hiding

A common anti pattern with responsive Vue components: two complete variants of a component are written into the markup at the same time, one of them hidden with hidden md:block. For simple visibility differences, such as an extra icon only on desktop, that is acceptable. For structurally completely different components with their own event handlers, their own state, and their own API calls, this becomes expensive: both variants get mounted, both variants run their onMounted hooks, both variants register event listeners.

The solution is consistent conditional rendering with v-if/v-else combined with the breakpoint composable, instead of v-show or CSS classes. v-if completely removes the unneeded variant from the DOM and the component tree, so lifecycle hooks only run for the variant actually active. For responsive Vue components with expensive child components, such as chart libraries or map components, this noticeably saves initialization time and memory.


// AVOID: both variants mount, both run lifecycle hooks and fetch data
// <DesktopChart class="hidden md:block" />
// <MobileChart class="md:hidden" />

// BETTER: only the active variant mounts and initializes
// <DesktopChart v-if="isDesktop" />
// <MobileChart v-else />

// Composable stays the single source of truth for both cases
import { useAppBreakpoints } from '@/composables/useAppBreakpoints'
const { isDesktop } = useAppBreakpoints()

A main navigation is the classic example of responsive Vue components where not just the appearance but the entire interaction logic changes. On desktop, a horizontal bar with hover triggered dropdown menus makes sense, while mobile devices instead need an off canvas menu with touch friendly, larger tap targets and expandable submenus instead of hover dropdowns.

The clean approach combines the breakpoint composable from section two with two completely separate components that share the same data source for the navigation structure. The actual navigation data, meaning labels, links, and submenus, comes from a shared configuration or composable, while DesktopNav and MobileNav each implement their own interaction logic suited to the context.


// composables/useNavigation.js — shared data source for both nav variants
export function useNavigation() {
  const items = [
    { label: 'Products', to: '/products', children: [
      { label: 'Electronics', to: '/products/electronics' },
      { label: 'Clothing', to: '/products/clothing' },
    ]},
    { label: 'About', to: '/about' },
    { label: 'Contact', to: '/contact' },
  ]
  return { items }
}

// DesktopNav.vue uses hover-triggered dropdowns for `items`
// MobileNav.vue uses an off-canvas panel with expandable accordions for `items`
// Both read from the same useNavigation() composable, avoiding duplicated data

This separation prevents navigation data from having to be maintained in two places, while each variant still keeps its own interaction logic optimized for the respective screen context. A common mistake is trying to build a single navigation component with countless conditional classes for both cases, which quickly becomes unmanageable once hover states and touch gestures need to be supported at the same time.

7. Practical example: tables turning into cards on small screens

Data tables are another classic among responsive Vue components, because a table with many columns on a narrow mobile screen either has to be scrolled horizontally or should switch to a completely different layout. The common solution turns each table row into a card on small screens, where each column appears as a label value pair instead of a table cell.

Technically, this can be solved elegantly with the same data structure for both representations: a shared column configuration defines the label and value access per column, and depending on the breakpoint the component either renders a table structure or a list of cards that iterates over the same column configuration.


// composables/useColumns.js — shared column config for table AND card views
export function useProductColumns() {
  const columns = [
    { key: 'name', label: 'Name' },
    { key: 'sku', label: 'SKU' },
    { key: 'stock', label: 'Stock' },
    { key: 'price', label: 'Price' },
  ]
  return { columns }
}

// AdaptiveTable.vue — script setup block
import { useAppBreakpoints } from '@/composables/useAppBreakpoints'
import { useProductColumns } from '@/composables/useColumns'

const { isMobile } = useAppBreakpoints()
const { columns } = useProductColumns()

defineProps({ rows: { type: Array, required: true } })

// template (desktop): a plain <table> iterating `columns` per <th>/<td>
// template (mobile, v-if="isMobile"): one card per row, each column
// rendered as a label/value pair using the same `columns` array

The decisive advantage of this pattern: if the column structure changes, for example a new column for warehouse location, it only needs to be added once in useProductColumns, and both the table and card representation pick up the change automatically. Without this shared configuration, two independently maintained markup structures tend to appear, each requiring double adjustments on every change and, in practice, quickly drifting apart.

8. Testing responsive components without a real browser resize

Automated tests for responsive Vue components often fail because test environments like Vitest with Happy DOM or JSDOM have no real viewport whose width can be changed. Anyone who couples breakpoint logic directly to window.innerWidth can hardly trigger it reliably in component tests. That is why it pays off to build the breakpoint composable so it can be replaced by a mock in tests, instead of requiring real browser events.

Because useAppBreakpoints from section two is a pure composable with reactive return values, it can be easily mocked in tests, without simulating real resize behavior. For container query based components, an additional visual regression test with Playwright makes sense, because pure CSS behavior can hardly be checked reliably in component tests.


// AdaptiveTable.spec.js — mocking the breakpoint composable in Vitest
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AdaptiveTable from '@/components/AdaptiveTable.vue'

vi.mock('@/composables/useAppBreakpoints', () => ({
  useAppBreakpoints: () => ({ isMobile: { value: true } }),
}))

describe('AdaptiveTable', () => {
  it('renders card layout when isMobile is true', () => {
    const wrapper = mount(AdaptiveTable, { props: { rows: [{ name: 'Test' }] } })
    expect(wrapper.find('[data-testid="mobile-card"]').exists()).toBe(true)
    expect(wrapper.find('table').exists()).toBe(false)
  })
})

9. Comparing approaches: media query, container query, JS breakpoint

For responsive Vue components there are three fundamental techniques that differ clearly in their scope and, in practice, complement rather than exclude one another.

Technique Reacts to Best suited for
CSS media query Viewport width Global page layouts, sidebar on/off
Container query Width of the parent element Reusable components in changing contexts
JS breakpoint composable Viewport width, reactive in JS Structural differences, loading other components

In practice, all three techniques get combined depending on the problem: CSS media queries for the rough page layout, container queries for individual reusable components, and JS breakpoint composables wherever not just styling but actual component structure or data loading behavior needs to change. Anyone trying to solve all three cases with only one technique ends up either with unnecessarily complex CSS or with JavaScript logic for simple visual adjustments that CSS alone would solve better.

Mironsoft

Vue development, responsive design systems, and component architecture

Components that work everywhere, no matter where they land?

We build reusable Vue components with container queries, breakpoint composables, and a clean testing strategy, so responsive behavior does not need to be reinvented with every new integration.

Component audit

Reviewing existing components for media query anti patterns

Container queries

Decoupling layout logic from viewport width

Testable composables

Structuring breakpoint logic to be mockable and maintainable

10. Summary

Responsive Vue components need more than plain CSS media queries as soon as they get reused in different layout contexts. Container queries solve the core problem by making components react to the width of their own container instead of the viewport width. A central breakpoint composable provides reactive JavaScript values for structural decisions that go beyond styling, for example completely different navigation components for mobile and desktop.

Adaptive slots give callers control over concrete content per breakpoint, while conditional rendering with v-if prevents unused component variants from wasting resources unnecessarily. Shared data structures such as column configurations prevent duplicate maintenance between table and card representations. Anyone combining these patterns gets responsive Vue components that genuinely work everywhere, regardless of which context they get embedded in.

Responsive Vue component patterns — the essentials at a glance

Container queries

React to the width of the parent element, not the viewport. Ideal for reusable components.

Breakpoint composable

Central, reactive source of truth for structural decisions beyond plain CSS.

Conditional rendering

v-if/v-else instead of CSS hiding saves lifecycle overhead and duplicate listeners.

Shared data sources

Define column and navigation configuration once, reuse across multiple layout variants.

11. FAQ: Responsive Vue Component Patterns

1Why are media queries often not enough?
They only react to viewport width, not to the actually available width of a reused component.
2Container query versus media query?
Media query checks viewport width, container query checks the width of the component's parent element.
3When to use a JS breakpoint composable?
When structure or behavior needs to change, not just styling, for example a different navigation component.
4Should JS and CSS breakpoints match?
Yes, otherwise layout jumps occur from differing decisions between CSS and JavaScript.
5Why v-if instead of v-show?
v-if fully removes the unused variant from the DOM, saving lifecycle overhead and duplicate listeners.
6Testing without a real resize?
Build the breakpoint composable as a mockable module, mock its return value directly in Vitest.
7Browser support for container queries?
All current Chrome, Firefox, Safari, and Edge versions fully support container queries.
8Avoiding duplicate navigation data?
Through a shared composable that desktop and mobile navigation both read data from.
9Making tables responsive?
Shared column configuration rendered as a table or card list depending on the breakpoint.
10Combining both techniques?
Yes, common in practice: container queries for fine layout, composable for structural decisions.