Skip to content

useResponsivePreview ​

The device preview's state. It picks a width; the component renders an iframe at it, so the page reflows because it genuinely is that width.

Open in demo

Import ​

ts
import {
  PREVIEW_DEVICE_GROUPS,
  PREVIEW_SIZE_LIMITS,
  previewSizeId,
  useResponsivePreview,
} from '@shared/composables/useResponsivePreview'

Signature ​

ts
function useResponsivePreview(): {
  isOpen: Ref<boolean>
  activeSizeId: Ref<string>
  activeSize: ComputedRef<PreviewSize | null>
  sizes: ComputedRef<PreviewSize[]>
  quickSizes: ComputedRef<PreviewSize[]>
  customSizes: ComputedRef<PreviewSize[]>
  groups: PreviewDeviceGroup[]
  zoom: WritableComputedRef<PreviewZoom>
  open: (sizeId?: string) => void
  close: () => void
  setSize: (sizeId: string) => void
  addSize: (label: string, width: number, height: number) => { ok: boolean, error?: string }
  removeSize: (id: string) => void
}

Returns ​

MemberTypeDescription
isOpenRef<boolean>Whether the preview is showing. Reset on reload, by design.
activeSizeIdRef<string>The chosen id. Starts at 'mobile'.
activeSizeComputedRef<PreviewSize | null>The resolved size.
sizesComputedRef<PreviewSize[]>Everything in config.ui.previewSizes.
quickSizesComputedRef<PreviewSize[]>The four shipped presets — those with builtin.
customSizesComputedRef<PreviewSize[]>Sizes the reader added.
groupsPreviewDeviceGroup[]The device catalog, grouped as the dropdown lists it.
zoomWritableComputedRef<PreviewZoom>'fit' or a percentage. Persisted.
open(sizeId?) => voidOpens, optionally switching size first.
close() => voidCloses it.
setSize(sizeId) => voidSwitches size.
addSize(label, width, height) => { ok, error? }Validates, stores and selects. Never throws.
removeSize(id) => voidDrops a size from the config.

PreviewSize ​

KeyTypeDescription
idstringStable identity; the active size is remembered by it.
labelstringWhat the picker shows.
widthnumberThe real viewport the iframe gets, so it decides which media queries fire.
heightnumberThe frame's height. It is not a breakpoint — nothing keys off it — but it is most of what makes a preview read as a phone rather than a narrow desktop.
builtinbooleanShipped presets, which Settings will not delete.

The four presets ​

PresetWidthLands in
Mobile375Below md — the drawer nav.
Tablet768Exactly md — the icon rail.
Laptop1024lg — the full sidebar.
Desktop1440lg and up.

Each preset sits in a different breakpoint band on purpose. Two presets that render identically teach nothing.

The catalog ​

PREVIEW_DEVICE_GROUPS holds fifteen real device viewports in four groups — mobile, tablet, laptop and desktop — in CSS pixels, portrait. The frame's own Rotate handles landscape, so a second entry per device would add nothing. Catalog ids are prefixed dev-, so one can never collide with a configured size, even if a reader names a custom size after the same phone.

Example ​

vue
<script setup lang="ts">
import { PREVIEW_SIZE_LIMITS, useResponsivePreview } from '@shared/composables/useResponsivePreview'
import { ref } from 'vue'

const { quickSizes, customSizes, activeSize, open, setSize, addSize, removeSize } = useResponsivePreview()

const label = ref('')
const width = ref(414)
const height = ref(896)
const error = ref('')

function add() {
  const result = addSize(label.value, width.value, height.value)
  error.value = result.ok ? '' : result.error ?? ''
  if (result.ok)
    label.value = ''
}
</script>

<template>
  <div class="space-y-3">
    <div class="flex flex-wrap gap-2">
      <button
        v-for="size in quickSizes"
        :key="size.id"
        type="button"
        :aria-pressed="activeSize?.id === size.id"
        @click="setSize(size.id)"
      >
        {{ size.label }} ({{ size.width }})
      </button>
    </div>

    <form class="flex flex-wrap gap-2" @submit.prevent="add()">
      <input v-model="label" type="text" placeholder="Name">
      <input
        v-model.number="width"
        type="number"
        :min="PREVIEW_SIZE_LIMITS.minWidth"
        :max="PREVIEW_SIZE_LIMITS.maxWidth"
      >
      <input
        v-model.number="height"
        type="number"
        :min="PREVIEW_SIZE_LIMITS.minHeight"
        :max="PREVIEW_SIZE_LIMITS.maxHeight"
      >
      <button type="submit">
        Add
      </button>
    </form>

    <p v-if="error">
      {{ error }}
    </p>

    <ul>
      <li v-for="size in customSizes" :key="size.id">
        {{ size.label }}
        <button type="button" @click="removeSize(size.id)">
          Remove
        </button>
      </li>
    </ul>

    <button type="button" @click="open('tablet')">
      Preview as a tablet
    </button>
  </div>
</template>

Notes ​

  • activeSize falls back three times: the configured sizes, then the catalog, then the first configured size. A deleted custom size, or an id from an older session, still lands somewhere real instead of blanking the frame.
  • addSize returns a result rather than throwing. It checks a non-empty name, the width and height bounds in PREVIEW_SIZE_LIMITS (240–3840 by 320–2400), and a duplicate id. Both Settings and the preview's own form call it, so the two cannot validate differently.
  • The id for a custom size is previewSizeId(label, width) — a slug plus the width — so two sizes with the same name at different widths coexist.
  • zoom is a writable computed over the config, so the last choice survives a reload. isOpen and activeSizeId are plain refs and do not.
  • The whole feature is behind config.features.responsivePreview.
  • The preview loads the real app in the iframe, which is why isPreviewFrame() in useResponsiveLayout exists — the embedded copy must not draw its own preview button.