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.
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
| Member | Type | Description |
|---|---|---|
isOpen | Ref<boolean> | Whether the preview is showing. Reset on reload, by design. |
activeSizeId | Ref<string> | The chosen id. Starts at 'mobile'. |
activeSize | ComputedRef<PreviewSize | null> | The resolved size. |
sizes | ComputedRef<PreviewSize[]> | Everything in config.ui.previewSizes. |
quickSizes | ComputedRef<PreviewSize[]> | The four shipped presets — those with builtin. |
customSizes | ComputedRef<PreviewSize[]> | Sizes the reader added. |
groups | PreviewDeviceGroup[] | The device catalog, grouped as the dropdown lists it. |
zoom | WritableComputedRef<PreviewZoom> | 'fit' or a percentage. Persisted. |
open | (sizeId?) => void | Opens, optionally switching size first. |
close | () => void | Closes it. |
setSize | (sizeId) => void | Switches size. |
addSize | (label, width, height) => { ok, error? } | Validates, stores and selects. Never throws. |
removeSize | (id) => void | Drops a size from the config. |
PreviewSize
| Key | Type | Description |
|---|---|---|
id | string | Stable identity; the active size is remembered by it. |
label | string | What the picker shows. |
width | number | The real viewport the iframe gets, so it decides which media queries fire. |
height | number | The 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. |
builtin | boolean | Shipped presets, which Settings will not delete. |
The four presets
| Preset | Width | Lands in |
|---|---|---|
| Mobile | 375 | Below md — the drawer nav. |
| Tablet | 768 | Exactly md — the icon rail. |
| Laptop | 1024 | lg — the full sidebar. |
| Desktop | 1440 | lg 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
activeSizefalls 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.addSizereturns a result rather than throwing. It checks a non-empty name, the width and height bounds inPREVIEW_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. zoomis a writable computed over the config, so the last choice survives a reload.isOpenandactiveSizeIdare 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()inuseResponsiveLayoutexists — the embedded copy must not draw its own preview button.
Related
- Global config —
previewSizes,previewZoomand the feature flag. useResponsiveLayout— the breakpoints the widths land in.- Layouts — what changes at each one.