さいしょに

デザインには、それを組んだときの画面幅があります。これを基準幅と呼びます。本文ブロックの幅と文字サイズの比は、基準幅の上で決まっています。基準幅より狭い画面ではブロックの幅だけが縮み、文字サイズはpxで書いた値のまま残ります。すると1行に入る文字数が減って、改行位置がずれます。

1行の文字数が変わると、行数も変わります。行数が変わると縦に伸びるので、1画面に収める設計だったのに崩れちゃったとかが起こりますね。改行位置に意味がある場合はなおさらで、句読点の直後で切りたかった行が、途中で折り返されてしまいます。

崩れる原因は幅と文字サイズの比が変わることなので、比を保てば直ります。つまり幅がn倍になるなら、文字サイズもn倍にすればよい。

1pxの長さをこちらで決める

CSS変数に長さを入れておき、寸法はその倍数で書きます。たとえば--sp-unitが0.82pxなら、calc(14 * var(--sp-unit))は11.49pxになります。文字も幅も余白も同じ変数を掛けるので、比は絶対に変わりません。

390pxの画面 1単位 = 1px 本文14px / 幅300px 320pxの画面 1単位 = 0.82px 本文11.49px / 幅246px 比が変わらないので 1行に入る文字数も 改行位置も同じ

単位を配る

幅は指定値ではなく実測値から取ります。100vwはスクロールバーの幅を含むので、縦スクロールのあるページでは数pxずれます。

const applyUnit = (el, designWidth = 390) => {
  // 基準幅より広い画面では等倍のまま。拡大はしない
  const scale = Math.min(1, el.clientWidth / designWidth)
  el.style.setProperty('--sp-unit', `${scale}px`)
}

寸法を倍数で書く

.page {
  /* 変数が来る前でも壊れないようフォールバックを置く */
  --u: var(--sp-unit, 1px);
  padding: 0 calc(16 * var(--u)) calc(16 * var(--u));
}

.page__body {
  width: calc(300 * var(--u));
  font-size: calc(14 * var(--u));
  line-height: 2;
  /* pxではなくem。フォントサイズに追従する */
  letter-spacing: 0.05em;
}

--uという短い別名を置くと、calc(14 * var(--u))が14デザインpxとして読めます。

composableにまとめる

Vueなら、幅を測ってstyleに流し込むcomposableにしておくと、ページ側は変数を受け取るだけになります。

import { computed, onBeforeUnmount, onMounted, ref, type ShallowRef } from 'vue'

/** 実測幅と基準幅の比からデザイン座標系の1pxを決める */
export function useSpUnit(target: Readonly<ShallowRef<HTMLElement | null>>, designWidth: number) {
  const scale = ref(1)
  let observer: ResizeObserver | null = null

  const update = (host: HTMLElement) => {
    if (host.clientWidth > 0) scale.value = Math.min(1, host.clientWidth / designWidth)
  }

  onMounted(() => {
    // 測るのは自分ではなく親。自分を測ると値が幅を変える循環になる
    const host = target.value?.parentElement
    if (!host) return
    update(host)
    if (typeof ResizeObserver !== 'undefined') {
      observer = new ResizeObserver(() => update(host))
      observer.observe(host)
    }
  })

  onBeforeUnmount(() => observer?.disconnect())

  return computed(() => ({ '--sp-unit': `${scale.value}px` }))
}
<script setup lang="ts">
const rootEl = useTemplateRef('rootEl')
const unitStyle = useSpUnit(rootEl, 390)
</script>

<template>
  <div ref="rootEl" class="page" :style="unitStyle">...</div>
</template>

親要素を測るのは、自分の幅が--sp-unitに依存しているからです。自分を測ると、測り直しが値を変え、変わった値がまた幅を変える循環になります。親要素は画面から幅が決まるので、その循環に入りません。

導入の流れ

ルートに--sp-unitを配る → --u: var(--sp-unit, 1px)で別名を作る → pxで書いた寸法をcalc(N * var(--u))に置き換える。

置き換えるのは、フォントサイズ、テキストブロックの幅、余白、gap、絶対配置の位置です。字間はemへ。aspect-ratioのように比率で書いてあるものは、そのままで動きます。

気をつけるところ

  • 字間をpxで書いていると、文字だけ縮んで字間が残り、1行に入る文字数が変わります。emに直せばフォントサイズに追従します。
  • 枠線やhairlineは縮めないほうが素直です。1pxを割る線はブラウザによって消えたり濃さが変わったりします。
  • 共通コンポーネントはvar(--sp-unit, 1px)のフォールバック付きで書いておくと、変数を配っていないページの見た目が変わりません。
  • 画面の高さ基準の値(svhなど)は縮めません。縮尺の対象は横幅から決まる寸法だけです。
  • 小数の寸法が並ぶので、隣り合う要素の境目が1px揺れることがあります。ぴったり合わせたい箇所は同じ計算式から作ります。