1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSS clamp() で fluid typography を自動計算するツールを作った — 中身の線形補間とアクセシビリティを保つ rem 表現

1
Posted at

font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem) のような fluid typography はもうレスポンシブ実装の標準になっている。media query なしで viewport 連動。だが「最小 16px、最大 24px、320px から 1280px の範囲でリニアに」と決まった時、中の係数を手計算する人はほぼいない。Utopia.fyi などの計算機にいつもお世話になっている。今回その種類のツールを 500 行 vanilla JS で書いた。線形補間の数学rem を使うアクセシビリティ的な配慮 を解説する。

🌐 Demo: https://sen.ltd/portfolio/css-clamp-calc/
📦 GitHub: https://github.com/sen-ltd/css-clamp-calc

Screenshot

fluid typography とは

/* 旧: media query で段階的に切替 */
font-size: 16px;
@media (min-width: 768px)  { font-size: 20px; }
@media (min-width: 1200px) { font-size: 24px; }

/* 新: clamp() で viewport 連動 */
font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem);

下の clamp()viewport 幅に対して線形にサイズが変わる 関数。clamp(min, preferred, max) 形式で、preferred が viewport 連動の式、両端で min/max にクランプされる。

clamp() の中身の数学

「viewport が 320px のときに 16px、1280px のときに 24px」を「viewport の関数として線形補間する」と:

size(vwPx) = 16 + (24 - 16) × (vwPx - 320) / (1280 - 320)
           = 16 + 8 × (vwPx - 320) / 960
           = (16 - 8 × 320/960) + (8/960) × vwPx
           = (16 - 2.667) + 0.00833 × vwPx
           = 13.333 + 0.00833 × vwPx  [px]

これを「intercept + slope × vw」の形に書き直す。CSS の vw 単位は viewport の 1% なので、vwPx = viewport_px = 100 × vw。よって:

size = 13.333 + 0.00833 × 100 × vw
     = 13.333 + 0.833 × vw  [px]

px → rem (16px = 1rem) で換算:

intercept = 13.333 / 16 = 0.833 rem
slope     = 0.833 vw     (vw単位はそのまま)

そして clamp() で包む:

font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);

これが手計算。実装としてはこれを関数に閉じ込める:

export function fluidLinear(minPx, maxPx, minVwPx, maxVwPx) {
  if (maxVwPx === minVwPx) {
    return { slopeVw: 0, interceptRem: minPx / 16, slopePx: 0, interceptPx: minPx };
  }
  const slopePx = (maxPx - minPx) / (maxVwPx - minVwPx);
  const interceptPx = minPx - slopePx * minVwPx;
  const slopeVw = slopePx * 100;
  const interceptRem = interceptPx / 16;
  return { slopeVw, interceptRem, slopePx, interceptPx };
}

なぜ rem を使うか — アクセシビリティ

ここが地味だが重要なポイント。intercept を rem で出すと、ユーザがブラウザの「文字サイズ」を変えたとき正しく追従する

/* ❌ ピクセルハードコード — ユーザ設定を無視する */
font-size: clamp(16px, 13.33px + 0.833vw, 24px);

/* ✅ rem ベース — ユーザの root font-size に従う */
font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);

ユーザが Chrome の Settings > Appearance > Font size で「Large (20px)」に設定した場合:

  • ピクセル版: 何も変わらない (16px 固定で押し付ける)
  • rem 版: 1rem = 20px になるので全体が 25% 大きくなる

ピクセル固定はアクセシビリティ的な反パターン。fluid typography で slope は vw のまま、intercept は rem に換算する のが正解。

slope の vwviewport の比率 なので、viewport size が変わったときの追従としては rem 換算不要 (むしろ rem に換算すると意味が壊れる)。

clamp() のクランプ挙動

clamp(min, preferred, max) は preferred が:

  • max を超えたら max を返す
  • min を下回ったら min を返す
  • それ以外は preferred そのまま

つまり viewport が 320px 未満では min 固定、1280px 超では max 固定、その間は線形。media query の代わりに 1 行で「上下を切る」 が出来るのが clamp() の本質。

ツール実装側:

export function evalAtViewport(minPx, maxPx, minVwPx, maxVwPx, vwPx) {
  const { slopePx, interceptPx } = fluidLinear(minPx, maxPx, minVwPx, maxVwPx);
  const linear = interceptPx + slopePx * vwPx;
  return Math.min(maxPx, Math.max(minPx, linear));
}

これがブラウザがレイアウト計算時にやっていることと等価。preview ボックスはこの関数を呼んで style.fontSize を更新するだけ。

「intercept が負になる」のは正しい

太い slope (大きいサイズ delta) になると、intercept が 負の値 になる:

minPx=10, maxPx=60, minVwPx=320, maxVwPx=1280
slopePx = 50/960 = 0.052
interceptPx = 10 - 0.052 × 320 = -6.67

CSS としては:

font-size: clamp(0.625rem, -0.417rem + 5.208vw, 3.75rem);

-0.417rem って大丈夫?」と一瞬思うが 全く問題ない。viewport が minVw=320px のとき:

-0.417rem + 5.208vw = -6.67px + 5.208 × 3.2px = -6.67 + 16.67 = 10px ✓

ちゃんと minPx=10px になる。clamp() の min 側でカットされる前に式自体が成立している。

ツールの notes パネルにも書いた:

Negative intercept is common and correct. For sharp slopes (large size delta), the intercept is often negative — that's why the math works out at the clamp's lower end. Don't "fix" it.

実装中に最初これを見て「バグかな」と思ったが、テストの「端点一致」が通っていたので冷静に式を追ったら正しいと分かった。

SVG chart で「視覚化」

数式だけだとピンと来ないので、size vs viewport の チャート も描いた:

// 60 サンプルで polyline 描画
for (let i = 0; i <= 60; i++) {
  const x = xMin + (i / 60) * (xMax - xMin);
  const y = evalAtViewport(minPx, maxPx, minVwPx, maxVwPx, x);
  points.push(`${xToPx(x)},${yToPx(y)}`);
}

2 本描画する:

  1. clamp された曲線 (実線、青) — 両端が水平
  2. clamp していない素の線形 (破線、薄青) — 両端が範囲外に伸びていく

両者を重ねると 「clamp() が何を切っているか」が一目で分かる。viewport slider を動かすと cursor (pink) がチャート上を移動して preview と同期する。

単体テストの境界条件

test("interpolation hits minPx at minVwPx", () => {
  const { slopePx, interceptPx } = fluidLinear(16, 24, 320, 1280);
  assert.ok(Math.abs((interceptPx + slopePx * 320) - 16) < 1e-9);
});

test("at minVw: returns minPx exactly", () => {
  const v = evalAtViewport(16, 24, 320, 1280, 320);
  assert.ok(Math.abs(v - 16) < 1e-9);
});

test("below minVw: returns minPx", () => {
  const v = evalAtViewport(16, 24, 320, 1280, 100);
  assert.equal(v, 16);
});

test("at maxVw: returns maxPx exactly", () => {
  const v = evalAtViewport(16, 24, 320, 1280, 1280);
  assert.ok(Math.abs(v - 24) < 1e-9);
});

「端点で minPx/maxPx に 完全に一致 する」のと「範囲外でクランプされる」のを別々の test に切るのが大事。境界バグはここで止まる。

degenerate viewport range (minVw === maxVw) は minPx を返す ような edge case も含めて 19 件。

7 種のプリセット

export const PRESETS = [
  { label: "Body text",      minPx: 16, maxPx: 18, minVwPx: 320, maxVwPx: 1280 },
  { label: "Lead paragraph", minPx: 18, maxPx: 22, minVwPx: 320, maxVwPx: 1280 },
  { label: "H3 heading",     minPx: 20, maxPx: 28, minVwPx: 320, maxVwPx: 1440 },
  { label: "H2 heading",     minPx: 24, maxPx: 40, minVwPx: 320, maxVwPx: 1440 },
  { label: "H1 hero",        minPx: 32, maxPx: 72, minVwPx: 320, maxVwPx: 1440 },
  { label: "Display",        minPx: 48, maxPx: 120, minVwPx: 320, maxVwPx: 1600 },
  { label: "Tight",          minPx: 14, maxPx: 16, minVwPx: 480, maxVwPx: 960 },
];

「Tight」は viewport 範囲を狭く取った プリセット — モバイル中盤 (480-960px) でだけスケールさせて他は固定、という需要に。

アーキテクチャ

clamp.js     ← fluidLinear, toClamp, evalAtViewport, validateConfig (DOM 非依存、19 tests)
presets.js   ← 7 種のプリセット
app.js       ← UI グルー (input → math → SVG draw → preview)

clamp.js には DOM が登場しない。Node テストで数学を完全に検証してから、UI 層は表示と入力ハンドリングだけを担当。drawChart() も SVG 文字列生成だけで、外部チャートライブラリは使わない。

まとめ

  • fluid typography は clamp(min, intercept + slope·vw, max) の 1 行で書ける
  • 線形補間の slope と intercept は端点 (minVw, minPx) / (maxVw, maxPx) から算術で出る
  • intercept は rem で表現する とユーザのフォントサイズ設定が効く (アクセシビリティ的に正解)
  • negative intercept は正しい — 大きい slope では普通に起きるし、両端で値は合っている
  • chart で clamp 前の素の線形 + clamp 後の曲線 を重ねると挙動が直感的に分かる
  • テストは「端点で 完全に一致」「範囲外でクランプ」を別 case に切ることで境界バグを止める

リポジトリ: https://github.com/sen-ltd/css-clamp-calc

このツールは弊社の OSS ポートフォリオ #254 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?